From 395c9a21ca4088c9a54e7269f69251d1a95b0034 Mon Sep 17 00:00:00 2001 From: ata-sesli Date: Tue, 10 Nov 2026 19:55:31 +0300 Subject: [PATCH 1/9] refactor(graph_buffer): port src/graph_buffer from C to Zig graph_buffer.c (2,184 lines) becomes graph_buffer.zig (1,831 lines). graph_buffer.h stays the ABI: the 42 exported functions keep their names and signatures, and checkExports verifies them against the header. Layout follows the C file section by section: the id/QN/label/name indexes, edge dedup and secondary indexes, cascade deletes, the dump builders, flush/merge into the store, and the vector and token-vector side tables. CBM_DYN_ARRAY is mirrored by a generic extern struct so the struct layout is unchanged. cbm_gbuf_new_shared_ids is exported without `pub`: translate-c cannot resolve its `_Atomic int64_t *` prototype, so checkExports cannot compare it. Its signature is checked by the C callers at link time only. Behavior differences: - Allocation failure in the dump, flush and merge paths returns CBM_NOT_FOUND where the C code dereferenced a NULL array. Flush and merge now allocate their id map before touching the store. - Negative node ids are rejected before they index an id map; C read before the start of the array. - The dump builder no longer allocates the `src_nodes` array (never read) and copies each edge's url_path/local_name once instead of twice. Wiring: Makefile.cbm drops GRAPH_BUFFER_SRCS from PROD_SRCS and LINT_SRCS and adds the Zig file and header to CBM_ZIG_SRCS; build.zig drops the C file; cbm_zig_c.h adds graph_buffer.h, semantic.h and the zova delta and publish-model headers the module calls. Signed-off-by: ata-sesli --- Makefile.cbm | 8 +- build.zig | 1 - src/cbm_zig.zig | 1 + src/cbm_zig_c.h | 4 + src/graph_buffer/graph_buffer.c | 2184 ----------------------------- src/graph_buffer/graph_buffer.zig | 1831 ++++++++++++++++++++++++ 6 files changed, 1840 insertions(+), 2189 deletions(-) delete mode 100644 src/graph_buffer/graph_buffer.c create mode 100644 src/graph_buffer/graph_buffer.zig diff --git a/Makefile.cbm b/Makefile.cbm index be5c4d01..5b353bb9 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -173,8 +173,7 @@ MCP_SRCS = # Discover module is Zig: see CBM_ZIG_SRC. -# Graph buffer module (new) -GRAPH_BUFFER_SRCS = src/graph_buffer/graph_buffer.c +# Graph buffer module is Zig: see CBM_ZIG_SRC. # Pipeline module (new) PIPELINE_SRCS = \ @@ -267,7 +266,7 @@ TRE_CFLAGS = -std=c11 -g -O1 -w -Ivendored/tre YYJSON_SRC = vendored/yyjson/yyjson.c # All production sources -PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(ZOVA_SRCS) $(MCP_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SEMANTIC_SRCS) $(CLI_SRCS) $(YYJSON_SRC) +PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(ZOVA_SRCS) $(MCP_SRCS) $(PIPELINE_SRCS) $(SEMANTIC_SRCS) $(CLI_SRCS) $(YYJSON_SRC) EXISTING_C_SRCS = $(EXTRACTION_SRCS) $(LSP_SRCS) $(TS_RUNTIME_SRC) \ $(GRAMMAR_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) @@ -492,6 +491,7 @@ CBM_ZIG_SRCS = $(CBM_ZIG_SRC) src/cbm_zig_c.h src/cli_zig/cbm_cli_zig.zig src/cl src/discover/userconfig.zig \ src/discover/discover.h src/discover/userconfig.h \ src/git/git_context.zig src/git/git_context.h \ + src/graph_buffer/graph_buffer.zig src/graph_buffer/graph_buffer.h \ src/simhash/minhash.zig src/simhash/minhash.h \ src/watcher/watcher.zig src/watcher/watcher.h CBM_ZIG_C_INCLUDES = -Isrc -Ivendored -Ivendored/sqlite3 -Ivendored/mimalloc/include \ @@ -820,7 +820,7 @@ SYSROOT_FLAG = $(if $(SYSROOT),-isysroot $(SYSROOT),) # Our source files (excluding vendored, grammars, tree-sitter runtime) LINT_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(MCP_SRCS) \ - $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SEMANTIC_SRCS) \ + $(PIPELINE_SRCS) $(SEMANTIC_SRCS) \ $(CLI_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) \ $(ZSTD_SRCS) $(MAIN_SRC) LINT_HDRS = $(wildcard src/**/*.h src/*.h $(CBM_DIR)/*.h) diff --git a/build.zig b/build.zig index 636c93cd..eaa591d5 100644 --- a/build.zig +++ b/build.zig @@ -545,7 +545,6 @@ const native_sources = [_][]const u8{ "src/zova/cbm_zova_repository.c", "src/zova/cbm_zova_bridge_stub.c", "src/zova/cbm_zova_plugin.c", - "src/graph_buffer/graph_buffer.c", "src/pipeline/fqn.c", "src/pipeline/path_alias.c", "src/pipeline/laravel_routing.c", diff --git a/src/cbm_zig.zig b/src/cbm_zig.zig index 9dbbe776..156ebafe 100644 --- a/src/cbm_zig.zig +++ b/src/cbm_zig.zig @@ -52,6 +52,7 @@ comptime { checkExports(@import("foundation/system_info.zig").linux); checkExports(@import("foundation/yaml.zig")); checkExports(@import("git/git_context.zig")); + checkExports(@import("graph_buffer/graph_buffer.zig")); checkExports(@import("mcp/code_tools.zig")); checkExports(@import("mcp/graph_tools.zig")); checkExports(@import("mcp/index_supervisor.zig")); diff --git a/src/cbm_zig_c.h b/src/cbm_zig_c.h index e4b2dd70..496d6d50 100644 --- a/src/cbm_zig_c.h +++ b/src/cbm_zig_c.h @@ -91,10 +91,13 @@ #include "foundation/yaml.h" #include "mcp/mcp.h" #include "pipeline/pipeline.h" +#include "semantic/semantic.h" #include "store/store.h" #include "tree_sitter/api.h" #include "zova/cbm_zova_coordination.h" +#include "zova/cbm_zova_delta.h" #include "zova/cbm_zova_operations.h" +#include "zova/cbm_zova_publish_model.h" #include "zova/cbm_zova_repository.h" #include "zova/cbm_zova_route.h" #include @@ -121,6 +124,7 @@ static inline FILE *cbm_zig_stderr(void) { #include "discover/discover.h" #include "discover/userconfig.h" #include "git/git_context.h" +#include "graph_buffer/graph_buffer.h" #include "mcp/index_supervisor.h" #include "simhash/minhash.h" #include "watcher/watcher.h" diff --git a/src/graph_buffer/graph_buffer.c b/src/graph_buffer/graph_buffer.c deleted file mode 100644 index c59060f5..00000000 --- a/src/graph_buffer/graph_buffer.c +++ /dev/null @@ -1,2184 +0,0 @@ -/* - * graph_buffer.c — In-memory graph buffer for pipeline indexing. - * - * Uses foundation hash tables for O(1) node lookup by QN and edge dedup. - * Uses dynamic arrays for ordered iteration and secondary indexes. - * - * Memory ownership: each node/edge is individually heap-allocated so that - * pointers stored in hash tables remain stable when the pointer-array grows. - * The buffer frees everything in cbm_gbuf_free(). - */ -#include "foundation/constants.h" -#include "semantic/semantic.h" -#include "zova/cbm_zova.h" -#include "zova/cbm_zova_publish_model.h" -#include "zova/cbm_zova_delta.h" - -enum { - GB_ERR = -1, - GB_COL_2 = 2, - GB_COL_3 = 3, - GB_COL_4 = 4, - GB_COL_5 = 5, - GB_COL_6 = 6, - GB_COL_7 = 7, - GB_URL_PATH_PREFIX = 12, /* strlen(""url_path":"") */ - GB_MIN_FOR_DEDUP = 2, /* need at least 2 vectors to sort+dedup */ - GB_DEDUP_LOOKAHEAD = 1, /* compare current with next element */ -}; -#include "graph_buffer/graph_buffer.h" -#include // url_path extraction must match json_extract semantics -#include "store/store.h" -#include "dump_types.h" -#include "foundation/hash_table.h" -#include "foundation/compat.h" -#include "foundation/log.h" -#include "foundation/dyn_array.h" -#include "foundation/profile.h" -#include "foundation/mem.h" -#include - -#include -#include // int64_t -#include -#include -#include // strdup -#include - -/* ── Internal types ──────────────────────────────────────────────── */ - -/* Edge key for dedup hash table — composite key as string "srcID:tgtID:type", - * plus ":local_name" for IMPORTS edges (#768). 256 bytes fit two int64s, the - * type and a ~200-char local_name verbatim; longer local_names are re-keyed - * with a hash of the full name in make_edge_key (never silently truncated). */ -#define EDGE_KEY_BUF CBM_SZ_256 - -/* Per-type or per-key edge list stored in hash tables as values */ -typedef CBM_DYN_ARRAY(const cbm_gbuf_edge_t *) edge_ptr_array_t; - -/* Per-label or per-name node list */ -typedef CBM_DYN_ARRAY(const cbm_gbuf_node_t *) node_ptr_array_t; - -struct cbm_gbuf { - char *project; - char *root_path; - int64_t next_id; - _Atomic int64_t *shared_ids; /* NULL = use next_id, non-NULL = atomic source */ - - /* Node storage: array of pointers to individually heap-allocated nodes. - * This ensures pointers stored in hash tables remain valid when the - * pointer array reallocs (only the pointer array moves, not the nodes). */ - CBM_DYN_ARRAY(cbm_gbuf_node_t *) nodes; - - /* Primary index: QN → cbm_gbuf_node_t* */ - CBMHashTable *node_by_qn; - /* Primary index: "id" string → cbm_gbuf_node_t* */ - /* Dense id → node array (ids are sequential from alloc_next_id, shared - * with edges → holes where edges took ids). Replaces a hash table keyed - * on STRDUP'D DECIMAL STRINGS of the id — ~0.44 GB of buckets + key - * strings at kernel scale, plus a snprintf+strdup+hash on every one of - * the ~18 hot find_by_id call sites. */ - cbm_gbuf_node_t **by_id; - int64_t by_id_cap; - - /* Secondary node indexes */ - CBMHashTable *nodes_by_label; /* key: label, value: (node_ptr_array_t*) */ - CBMHashTable *nodes_by_name; /* key: name, value: (node_ptr_array_t*) */ - - /* Edge storage: array of pointers to individually heap-allocated edges */ - CBM_DYN_ARRAY(cbm_gbuf_edge_t *) edges; - - /* Edge dedup index: "srcID:tgtID:type" → cbm_gbuf_edge_t* */ - CBMHashTable *edge_by_key; - - /* Edge secondary indexes: composite keys → edge_ptr_array_t */ - CBMHashTable *edges_by_source_type; /* "srcID:type" → edge_ptr_array_t* */ - CBMHashTable *edges_by_target_type; /* "tgtID:type" → edge_ptr_array_t* */ - CBMHashTable *edges_by_type; /* "type" → edge_ptr_array_t* */ - - /* String intern pool for highly-repetitive fields (node label/file_path, - * edge type). Maps string content → owned canonical copy, collapsing - * O(nodes+edges) duplicate allocations to O(distinct). The pool owns the - * copies; interned pointers are stable for the buffer lifetime and are NOT - * freed by free_node_strings/free_edge_strings — only once in cbm_gbuf_free. */ - CBMHashTable *intern_pool; - - /* Vector storage for semantic embeddings (filled by pass_semantic_edges, - * consumed by the cbm.zova publish during dump). */ - CBMDumpVector *dump_vectors; - int dump_vector_count; - int dump_vector_cap; - - /* Token vector storage for enriched RI vectors (query-time lookup). */ - CBMDumpTokenVec *dump_token_vecs; - int dump_token_vec_count; - int dump_token_vec_cap; - bool reuse_zova_semantic_vectors; - - /* Finalized lightweight topology retained after the SQLite writer closes. - * Sidecar publication happens after the pipeline persists hashes/FTS, so - * it must consume these arrays without re-scanning SQLite graph rows. */ - CBMDumpNode *zova_dump_nodes; - int zova_dump_node_count; - CBMDumpEdge *zova_dump_edges; - int zova_dump_edge_count; - cbm_zova_prepared_view_t *zova_prepared_view; - - /* The SQLite writer and shared user-local publisher must describe - * the same indexed generation. Keep the timestamp produced for the dump - * so publication does not create a second, later timestamp. */ - char indexed_at[CBM_SZ_64]; -}; - -static void free_retained_dump_edges(CBMDumpEdge *edges, int edge_count); - -/* ── Helpers ─────────────────────────────────────────────────────── */ - -/* Copy s, or "{}" when s is NULL. */ -static char *dup_or_empty_json(const char *s) { - return cbm_heap_strdup(s ? s : "{}"); -} - -/* Intern a repetitive string into the buffer's pool: identical content collapses - * to a single heap copy owned by the pool. NULL maps to "{}" (matches - * dup_or_empty_json). The returned pointer is stable for the buffer's lifetime and - * must never be freed or mutated by callers. Returns NULL only on OOM. */ -static const char *gb_intern(cbm_gbuf_t *gb, const char *s) { - const char *key = s ? s : "{}"; - const char *found = cbm_ht_get(gb->intern_pool, key); - if (found) { - return found; - } - char *copy = strdup(key); - if (copy) { - cbm_ht_set(gb->intern_pool, copy, copy); /* key == value == owned copy */ - } - return copy; -} - -static void make_id_key(char *buf, size_t bufsz, int64_t id) { - snprintf(buf, bufsz, "%lld", (long long)id); -} - -/* FNV-1a 64-bit over a byte slice — for re-keying oversized local_names. */ -static uint64_t fnv1a64(const char *s, size_t len) { - uint64_t h = 14695981039346656037ULL; - for (size_t i = 0; i < len; i++) { - h ^= (uint8_t)s[i]; - h *= 1099511628211ULL; - } - return h; -} - -/* IMPORTS edges carry exactly one imported symbol's local_name (#768): two - * named imports from the same specifier resolve to the same (source, - * target) pair but are distinct symbols. Key on local_name too so the - * second import doesn't dedup-collide with and overwrite the first — - * every pass that walks IMPORTS edges (the call resolver in pass_parallel.c, - * pass_lsp_cross.c) expects one local_name per edge, so - * losing an edge here silently breaks cross-file call resolution for - * whichever symbol got dropped, not just "who imports X" queries. Other - * edge types keep the plain (source,target,type) key: collapsing repeat - * edges of the same type between the same two nodes (e.g. multiple call - * sites) into one is the existing, intended dedup behavior there. - * - * A local_name too long for the key buffer is re-keyed with an FNV-1a hash - * of the FULL name instead of being truncated — a truncated key would - * collide two long names sharing a prefix and silently drop an edge again. - * The hash key is prefixed with byte 0x01, which cannot appear in the raw - * JSON slice (control characters must be \u-escaped in JSON), so hash keys - * can never collide with verbatim keys. */ -static void make_edge_key(char *buf, size_t bufsz, int64_t src, int64_t tgt, const char *type, - const char *properties_json) { - if (properties_json && strcmp(type, "IMPORTS") == 0) { - static const char local_name_key[] = "\"local_name\":\""; - const char *ln = strstr(properties_json, local_name_key); - if (ln) { - ln += sizeof(local_name_key) - 1; - const char *end = strchr(ln, '"'); - size_t ln_len = end ? (size_t)(end - ln) : strlen(ln); - int n = snprintf(buf, bufsz, "%lld:%lld:%s:%.*s", (long long)src, (long long)tgt, type, - (int)ln_len, ln); - if (n < 0 || (size_t)n >= bufsz) { - snprintf(buf, bufsz, "%lld:%lld:%s:\x01%016llx", (long long)src, (long long)tgt, - type, (unsigned long long)fnv1a64(ln, ln_len)); - } - return; - } - } - snprintf(buf, bufsz, "%lld:%lld:%s", (long long)src, (long long)tgt, type); -} - -static void make_src_type_key(char *buf, size_t bufsz, int64_t src, const char *type) { - snprintf(buf, bufsz, "%lld:%s", (long long)src, type); -} - -/* Get or create a node_ptr_array_t in a hash table */ -static node_ptr_array_t *get_or_create_node_array(CBMHashTable *ht, const char *key) { - node_ptr_array_t *arr = cbm_ht_get(ht, key); - if (!arr) { - arr = calloc(CBM_ALLOC_ONE, sizeof(node_ptr_array_t)); - cbm_ht_set(ht, strdup(key), arr); - } - return arr; -} - -/* Get or create an edge_ptr_array_t in a hash table */ -static edge_ptr_array_t *get_or_create_edge_array(CBMHashTable *ht, const char *key) { - edge_ptr_array_t *arr = cbm_ht_get(ht, key); - if (!arr) { - arr = calloc(CBM_ALLOC_ONE, sizeof(edge_ptr_array_t)); - cbm_ht_set(ht, strdup(key), arr); - } - return arr; -} - -/* Free a node_ptr_array_t (callback for hash table iteration) */ -static void free_node_array(const char *key, void *value, void *ud) { - (void)ud; - node_ptr_array_t *arr = value; - if (arr) { - cbm_da_free(arr); - free(arr); - } - free((void *)key); -} - -/* Free an edge_ptr_array_t (callback) */ -static void free_edge_array(const char *key, void *value, void *ud) { - (void)ud; - edge_ptr_array_t *arr = value; - if (arr) { - cbm_da_free(arr); - free(arr); - } - free((void *)key); -} - -/* Free keys only (for edge_by_key, deleted_set) */ -static void free_key_only(const char *key, void *value, void *ud) { - (void)value; - (void)ud; - free((void *)key); -} - -/* Free a single node's owned strings. label and file_path are interned - * (pool-owned) — NOT freed here; the pool frees them once in cbm_gbuf_free. */ -static void free_node_strings(cbm_gbuf_node_t *n) { - free(n->name); - free(n->qualified_name); - free(n->properties_json); -} - -/* Free a single edge's owned strings. type is interned (pool-owned) — NOT - * freed here; the pool frees it once in cbm_gbuf_free. */ -static void free_edge_strings(cbm_gbuf_edge_t *e) { - free(e->properties_json); -} - -/* Allocate the next buffer-local or shared-atomic ID. */ -static int64_t alloc_next_id(cbm_gbuf_t *gb) { - if (gb->shared_ids) { - return atomic_fetch_add_explicit(gb->shared_ids, SKIP_ONE, memory_order_relaxed); - } - return gb->next_id++; -} - -/* Swap-remove an edge from a pointer array by ID. */ -static void remove_edge_from_ptr_array(edge_ptr_array_t *arr, int64_t edge_id) { - if (!arr) { - return; - } - for (int j = 0; j < arr->count; j++) { - if (arr->items[j]->id == edge_id) { - arr->items[j] = arr->items[--arr->count]; - return; - } - } -} - -/* Swap-remove a node from a node_ptr_array by ID. */ -static void remove_node_from_ptr_array(node_ptr_array_t *arr, int64_t node_id) { - if (!arr) { - return; - } - for (int j = 0; j < arr->count; j++) { - if (arr->items[j]->id == node_id) { - arr->items[j] = arr->items[--arr->count]; - return; - } - } -} - -/* Remove an edge from all indexes (dedup + source_type + target_type + type). */ -static void unindex_edge(cbm_gbuf_t *gb, const cbm_gbuf_edge_t *e) { - char key[EDGE_KEY_BUF]; - - make_edge_key(key, sizeof(key), e->source_id, e->target_id, e->type, e->properties_json); - const char *ekey = cbm_ht_get_key(gb->edge_by_key, key); - cbm_ht_delete(gb->edge_by_key, key); - free((void *)ekey); - - make_src_type_key(key, sizeof(key), e->source_id, e->type); - remove_edge_from_ptr_array(cbm_ht_get(gb->edges_by_source_type, key), e->id); - - make_src_type_key(key, sizeof(key), e->target_id, e->type); - remove_edge_from_ptr_array(cbm_ht_get(gb->edges_by_target_type, key), e->id); - - remove_edge_from_ptr_array(cbm_ht_get(gb->edges_by_type, e->type), e->id); -} - -/* Cascade-delete all edges touching nodes in deleted_set. */ -static void cascade_delete_edges(cbm_gbuf_t *gb, CBMHashTable *deleted_set) { - int write_idx = 0; - for (int i = 0; i < gb->edges.count; i++) { - cbm_gbuf_edge_t *e = gb->edges.items[i]; - char src_id[CBM_SZ_32]; - char tgt_id[CBM_SZ_32]; - make_id_key(src_id, sizeof(src_id), e->source_id); - make_id_key(tgt_id, sizeof(tgt_id), e->target_id); - - if (cbm_ht_get(deleted_set, src_id) || cbm_ht_get(deleted_set, tgt_id)) { - unindex_edge(gb, e); - free_edge_strings(e); - free(e); - } else { - gb->edges.items[write_idx++] = gb->edges.items[i]; - } - } - gb->edges.count = write_idx; -} - -/* Register a node in primary (QN, ID) and secondary (label, name) indexes. */ -static void register_node_in_indexes(cbm_gbuf_t *gb, cbm_gbuf_node_t *node) { - cbm_ht_set(gb->node_by_qn, node->qualified_name, node); - - if (node->id >= gb->by_id_cap) { - int64_t nc = gb->by_id_cap > 0 ? gb->by_id_cap : CBM_SZ_1K; - while (nc <= node->id) { - nc *= 2; - } - cbm_gbuf_node_t **grown = realloc(gb->by_id, (size_t)nc * sizeof(*grown)); - if (grown) { - memset(grown + gb->by_id_cap, 0, (size_t)(nc - gb->by_id_cap) * sizeof(*grown)); - gb->by_id = grown; - gb->by_id_cap = nc; - } - } - if (node->id >= 0 && node->id < gb->by_id_cap) { - gb->by_id[node->id] = node; - } - - node_ptr_array_t *by_label = - get_or_create_node_array(gb->nodes_by_label, node->label ? node->label : ""); - cbm_da_push(by_label, (const cbm_gbuf_node_t *)node); - - node_ptr_array_t *by_name = - get_or_create_node_array(gb->nodes_by_name, node->name ? node->name : ""); - cbm_da_push(by_name, (const cbm_gbuf_node_t *)node); -} - -/* Push an edge pointer into a dynamic array (wraps macro to reduce CC contribution). */ -static void edge_array_push(edge_ptr_array_t *arr, const cbm_gbuf_edge_t *edge) { - cbm_da_push(arr, edge); -} - -/* Index an edge by one key into a hash table bucket. */ -static void index_edge_by_key(CBMHashTable *ht, const char *key, cbm_gbuf_edge_t *edge) { - edge_ptr_array_t *arr = get_or_create_edge_array(ht, key); - edge_array_push(arr, (const cbm_gbuf_edge_t *)edge); -} - -/* Register an edge in secondary indexes (source_type, target_type, type). */ -static void register_edge_in_indexes(cbm_gbuf_t *gb, cbm_gbuf_edge_t *edge) { - char key[EDGE_KEY_BUF]; - - make_src_type_key(key, sizeof(key), edge->source_id, edge->type); - index_edge_by_key(gb->edges_by_source_type, key, edge); - - make_src_type_key(key, sizeof(key), edge->target_id, edge->type); - index_edge_by_key(gb->edges_by_target_type, key, edge); - - index_edge_by_key(gb->edges_by_type, edge->type, edge); -} - -/* Rebuild edge secondary indexes from scratch (after bulk deletion). */ -static void rebuild_edge_secondary_indexes(cbm_gbuf_t *gb) { - cbm_ht_foreach(gb->edges_by_source_type, free_edge_array, NULL); - cbm_ht_free(gb->edges_by_source_type); - cbm_ht_foreach(gb->edges_by_target_type, free_edge_array, NULL); - cbm_ht_free(gb->edges_by_target_type); - cbm_ht_foreach(gb->edges_by_type, free_edge_array, NULL); - cbm_ht_free(gb->edges_by_type); - - gb->edges_by_source_type = cbm_ht_create(CBM_SZ_256); - gb->edges_by_target_type = cbm_ht_create(CBM_SZ_256); - gb->edges_by_type = cbm_ht_create(CBM_SZ_32); - - for (int i = 0; i < gb->edges.count; i++) { - register_edge_in_indexes(gb, gb->edges.items[i]); - } -} - -/* Release all lookup hash tables (used by dump after building arrays). */ -static void release_gbuf_indexes(cbm_gbuf_t *gb) { - cbm_ht_free(gb->node_by_qn); - gb->node_by_qn = NULL; - free(gb->by_id); - gb->by_id = NULL; - gb->by_id_cap = 0; - cbm_ht_foreach(gb->nodes_by_label, free_node_array, NULL); - cbm_ht_free(gb->nodes_by_label); - gb->nodes_by_label = NULL; - cbm_ht_foreach(gb->nodes_by_name, free_node_array, NULL); - cbm_ht_free(gb->nodes_by_name); - gb->nodes_by_name = NULL; - cbm_ht_foreach(gb->edge_by_key, free_key_only, NULL); - cbm_ht_free(gb->edge_by_key); - gb->edge_by_key = NULL; - cbm_ht_foreach(gb->edges_by_source_type, free_edge_array, NULL); - cbm_ht_free(gb->edges_by_source_type); - gb->edges_by_source_type = NULL; - cbm_ht_foreach(gb->edges_by_target_type, free_edge_array, NULL); - cbm_ht_free(gb->edges_by_target_type); - gb->edges_by_target_type = NULL; - cbm_ht_foreach(gb->edges_by_type, free_edge_array, NULL); - cbm_ht_free(gb->edges_by_type); - gb->edges_by_type = NULL; -} - -/* ── Lifecycle ──────────────────────────────────────────────────── */ - -cbm_gbuf_t *cbm_gbuf_new(const char *project, const char *root_path) { - cbm_gbuf_t *gb = calloc(CBM_ALLOC_ONE, sizeof(cbm_gbuf_t)); - if (!gb) { - return NULL; - } - - gb->project = strdup(project ? project : ""); - gb->root_path = strdup(root_path ? root_path : ""); - gb->next_id = SKIP_ONE; - gb->shared_ids = NULL; - - gb->node_by_qn = cbm_ht_create(CBM_SZ_256); - gb->by_id = NULL; - gb->by_id_cap = 0; - gb->nodes_by_label = cbm_ht_create(CBM_SZ_32); - gb->nodes_by_name = cbm_ht_create(CBM_SZ_256); - - gb->edge_by_key = cbm_ht_create(CBM_SZ_512); - gb->edges_by_source_type = cbm_ht_create(CBM_SZ_256); - gb->edges_by_target_type = cbm_ht_create(CBM_SZ_256); - gb->edges_by_type = cbm_ht_create(CBM_SZ_32); - - gb->intern_pool = cbm_ht_create(CBM_SZ_1K); - - return gb; -} - -cbm_gbuf_t *cbm_gbuf_new_shared_ids(const char *project, const char *root_path, - _Atomic int64_t *id_source) { - cbm_gbuf_t *gb = cbm_gbuf_new(project, root_path); - if (gb && id_source) { - gb->shared_ids = id_source; - } - return gb; -} - -int cbm_gbuf_reserve(cbm_gbuf_t *gb, int node_count, int edge_count) { - if (!gb || node_count < 0 || edge_count < 0) { - return CBM_NOT_FOUND; - } - if (node_count > gb->nodes.cap) { - cbm_gbuf_node_t **nodes = - realloc(gb->nodes.items, (size_t)node_count * sizeof(*nodes)); - if (!nodes) return CBM_NOT_FOUND; - gb->nodes.items = nodes; - gb->nodes.cap = node_count; - } - if (edge_count > gb->edges.cap) { - cbm_gbuf_edge_t **edges = - realloc(gb->edges.items, (size_t)edge_count * sizeof(*edges)); - if (!edges) return CBM_NOT_FOUND; - gb->edges.items = edges; - gb->edges.cap = edge_count; - } - return 0; -} - -void cbm_gbuf_free(cbm_gbuf_t *gb) { - if (!gb) { - return; - } - - free(gb->zova_dump_nodes); - free_retained_dump_edges(gb->zova_dump_edges, gb->zova_dump_edge_count); - cbm_zova_prepared_view_free(gb->zova_prepared_view); - - /* Free each individually-allocated node */ - for (int i = 0; i < gb->nodes.count; i++) { - cbm_gbuf_node_t *n = gb->nodes.items[i]; - free_node_strings(n); - free(n); - } - cbm_da_free(&gb->nodes); - - /* Free each individually-allocated edge */ - for (int i = 0; i < gb->edges.count; i++) { - cbm_gbuf_edge_t *e = gb->edges.items[i]; - free_edge_strings(e); - free(e); - } - cbm_da_free(&gb->edges); - - /* Free hash tables — may be NULL if already released by dump_to_sqlite */ - if (gb->node_by_qn) { - cbm_ht_free(gb->node_by_qn); - } - free(gb->by_id); - if (gb->nodes_by_label) { - cbm_ht_foreach(gb->nodes_by_label, free_node_array, NULL); - cbm_ht_free(gb->nodes_by_label); - } - if (gb->nodes_by_name) { - cbm_ht_foreach(gb->nodes_by_name, free_node_array, NULL); - cbm_ht_free(gb->nodes_by_name); - } - if (gb->edge_by_key) { - cbm_ht_foreach(gb->edge_by_key, free_key_only, NULL); - cbm_ht_free(gb->edge_by_key); - } - if (gb->edges_by_source_type) { - cbm_ht_foreach(gb->edges_by_source_type, free_edge_array, NULL); - cbm_ht_free(gb->edges_by_source_type); - } - if (gb->edges_by_target_type) { - cbm_ht_foreach(gb->edges_by_target_type, free_edge_array, NULL); - cbm_ht_free(gb->edges_by_target_type); - } - if (gb->edges_by_type) { - cbm_ht_foreach(gb->edges_by_type, free_edge_array, NULL); - cbm_ht_free(gb->edges_by_type); - } - - /* Free vector storage */ - for (int i = 0; i < gb->dump_vector_count; i++) { - free((void *)gb->dump_vectors[i].vector); - } - free(gb->dump_vectors); - - /* Free token vector storage */ - for (int i = 0; i < gb->dump_token_vec_count; i++) { - free((void *)gb->dump_token_vecs[i].token); - free((void *)gb->dump_token_vecs[i].vector); - } - free(gb->dump_token_vecs); - - /* Free interned strings (node label/file_path, edge type) — pool owns one - * copy each (key == value), freed exactly once via free_key_only. Done after - * nodes/edges since they borrowed these pointers. */ - if (gb->intern_pool) { - cbm_ht_foreach(gb->intern_pool, free_key_only, NULL); - cbm_ht_free(gb->intern_pool); - } - - free(gb->project); - free(gb->root_path); - free(gb); -} - -/* ── Vector storage ──────────────────────────────────────────────── */ - -void cbm_gbuf_clear_semantic_vectors(cbm_gbuf_t *gb) { - if (!gb) { - return; - } - for (int i = 0; i < gb->dump_vector_count; i++) { - free((void *)gb->dump_vectors[i].vector); - } - gb->dump_vector_count = 0; - for (int i = 0; i < gb->dump_token_vec_count; i++) { - free((void *)gb->dump_token_vecs[i].token); - free((void *)gb->dump_token_vecs[i].vector); - } - gb->dump_token_vec_count = 0; -} - -int cbm_gbuf_reuse_zova_semantic_vectors(cbm_gbuf_t *gb) { - if (!gb || gb->dump_vector_count != 0 || gb->dump_token_vec_count != 0) - return CBM_NOT_FOUND; - gb->reuse_zova_semantic_vectors = true; - return 0; -} - -int cbm_gbuf_store_vector(cbm_gbuf_t *gb, int64_t node_id, const uint8_t *vector, int vector_len) { - if (!gb || !vector || vector_len <= 0) { - return GB_ERR; - } - enum { VEC_INIT_CAP = 1024, VEC_GROW = 2 }; - if (gb->dump_vector_count >= gb->dump_vector_cap) { - int new_cap = - gb->dump_vector_cap < VEC_INIT_CAP ? VEC_INIT_CAP : gb->dump_vector_cap * VEC_GROW; - CBMDumpVector *grown = realloc(gb->dump_vectors, (size_t)new_cap * sizeof(CBMDumpVector)); - if (!grown) { - return GB_ERR; - } - gb->dump_vectors = grown; - gb->dump_vector_cap = new_cap; - } - /* Copy vector data */ - uint8_t *vec_copy = malloc((size_t)vector_len); - if (!vec_copy) { - return GB_ERR; - } - memcpy(vec_copy, vector, (size_t)vector_len); - - gb->dump_vectors[gb->dump_vector_count++] = (CBMDumpVector){ - .node_id = node_id, - .project = gb->project, /* borrowed — valid until gbuf_free */ - .vector = vec_copy, - .vector_len = vector_len, - }; - return 0; -} - -int cbm_gbuf_store_token_vector(cbm_gbuf_t *gb, const char *token, const uint8_t *vector, - int vector_len, float idf) { - if (!gb || !token || !vector || vector_len <= 0) { - return GB_ERR; - } - enum { TV_INIT_CAP = 256, TV_GROW = 2 }; - if (gb->dump_token_vec_count >= gb->dump_token_vec_cap) { - int new_cap = - gb->dump_token_vec_cap < TV_INIT_CAP ? TV_INIT_CAP : gb->dump_token_vec_cap * TV_GROW; - CBMDumpTokenVec *grown = - realloc(gb->dump_token_vecs, (size_t)new_cap * sizeof(CBMDumpTokenVec)); - if (!grown) { - return GB_ERR; - } - gb->dump_token_vecs = grown; - gb->dump_token_vec_cap = new_cap; - } - uint8_t *vec_copy = malloc((size_t)vector_len); - if (!vec_copy) { - return GB_ERR; - } - memcpy(vec_copy, vector, (size_t)vector_len); - - int idx = gb->dump_token_vec_count; - gb->dump_token_vecs[idx] = (CBMDumpTokenVec){ - .id = idx + SKIP_ONE, /* 1-based sequential ID */ - .project = gb->project, - .token = strdup(token), - .vector = vec_copy, - .vector_len = vector_len, - .idf = idf, - }; - gb->dump_token_vec_count++; - return 0; -} - -/* ── ID accessors ────────────────────────────────────────────────── */ - -int64_t cbm_gbuf_next_id(const cbm_gbuf_t *gb) { - if (!gb) { - return SKIP_ONE; - } - if (gb->shared_ids) { - return atomic_load(gb->shared_ids); - } - return gb->next_id; -} - -void cbm_gbuf_set_next_id(cbm_gbuf_t *gb, int64_t next_id) { - if (!gb) { - return; - } - gb->next_id = next_id; -} - -void cbm_gbuf_raise_next_id(cbm_gbuf_t *gb, int64_t next_id) { - if (gb && next_id > gb->next_id) { - gb->next_id = next_id; - } -} - -static int gbuf_id_cmp(const void *a, const void *b) { - int64_t x = *(const int64_t *)a; - int64_t y = *(const int64_t *)b; - return (x > y) - (x < y); -} - -/* Sorts ids, counts each id an earlier entry already carries, frees ids. */ -static int count_duplicate_ids(int64_t *ids, int n) { - qsort(ids, (size_t)n, sizeof(*ids), gbuf_id_cmp); - int dups = 0; - for (int i = 1; i < n; i++) { - dups += ids[i] == ids[i - SKIP_ONE]; - } - free(ids); - return dups; -} - -int cbm_gbuf_duplicate_edge_ids(const cbm_gbuf_t *gb) { - if (!gb || gb->edges.count < 2) { - return 0; - } - int n = gb->edges.count; - int64_t *ids = calloc((size_t)n, sizeof(*ids)); - if (!ids) { - return CBM_NOT_FOUND; - } - for (int i = 0; i < n; i++) { - ids[i] = gb->edges.items[i]->id; - } - return count_duplicate_ids(ids, n); -} - -int cbm_gbuf_duplicate_node_ids(const cbm_gbuf_t *gb) { - if (!gb || gb->nodes.count < 2) { - return 0; - } - int n = gb->nodes.count; - int64_t *ids = calloc((size_t)n, sizeof(*ids)); - if (!ids) { - return CBM_NOT_FOUND; - } - for (int i = 0; i < n; i++) { - ids[i] = gb->nodes.items[i]->id; - } - return count_duplicate_ids(ids, n); -} - -/* ── Node operations ─────────────────────────────────────────────── */ - -/* First key of the canonical same-QN collision order: how strong a claim the - * entity has on a qualified name. A macro is the weakest: when a "Macro" and - * a definition share a QN, the definition, which carries the body, the - * signature and every edge, owns it whichever comes first. - * - * C-preprocessor macros no longer get here: their QN carries its own - * namespace (`...#macro`, CBM_MACRO_QN_SUFFIX) and cannot equal a definition's. - * The case left is Chialisp, whose `defmacro` / `defmac` defs are labelled - * "Macro" under a plain QN: a macro and a `defun` of one name in one file, or - * in a same-stem `.clsp` / `.clib` pair (one module QN). */ -static int gb_qn_claim_rank(const char *label) { - return (label && strcmp(label, "Macro") == 0) ? 0 : 1; -} - -int64_t cbm_gbuf_upsert_node(cbm_gbuf_t *gb, const char *label, const char *name, - const char *qualified_name, const char *file_path, int start_line, - int end_line, const char *properties_json) { - if (!gb || !qualified_name) { - return 0; - } - - /* Check if node already exists */ - cbm_gbuf_node_t *existing = cbm_ht_get(gb->node_by_qn, qualified_name); - if (existing) { - /* Don't let a per-file "Module" def touch a structural directory node - * ("Project" root or "Folder"). In a directory-based-module language - * (Go/Java) a file's module_qn equals its directory QN: a root file → - * the project name (== the "Project" node's QN); a file in pkg/ → - * proj.pkg (== the "pkg/" Folder node's QN). Its always-emitted Module - * def collides here; the directory node is the package/module container - * and must keep its structural label AND its own name/file_path/range. - * Updating those in place set the shared node's file_path to whichever - * same-package file happened to be processed LAST (worker-order - * dependent) — the nondeterministic file attribution behind #787 — and - * left the Folder node exposed to delete-nodes-by-file on incremental - * reindex of that file. Skip the update entirely. (Both direct - * upserts and the parallel local-gbuf merge route through this function.) */ - if (existing->label && label && strcmp(label, "Module") == 0 && - (strcmp(existing->label, "Project") == 0 || strcmp(existing->label, "Folder") == 0)) { - return existing->id; - } - /* Same-QN arrival: distinct source entities can share a QN (C: a - * struct, a function and a macro with one name), and the same entity - * can be re-upserted with fresh content. The old code let the LAST - * arrival overwrite — under parallel extraction the merge order - * varies run to run, so WHICH entity survived flickered (xfs: - * Function-node count 4998 vs 5015 across two runs) and every - * order-sensitive consumer downstream inherited it. Pick the - * survivor by a canonical CONTENT rule instead, a pure function of - * the two candidates: smallest file_path, then LARGEST start_line, - * then largest name/label (a mixed-direction composite is still a - * total order, so the pick is commutative and scheduling-free). - * Line-descending within a file keeps the classic upsert contract — - * a later definition in the same file (macro redefinition, refresh - * of the same entity with new content) replaces the earlier one, - * deterministically, because intra-file arrival order is fixed. A - * full tie is the same entity re-upserted → refresh in place. - * Ahead of all of these, a definition beats a macro - * (gb_qn_claim_rank). Kind-disambiguated QNs (the real cure) remain - * a follow-up. */ - int c = gb_qn_claim_rank(existing->label) - gb_qn_claim_rank(label); - if (c == 0) { - c = strcmp(file_path ? file_path : "", existing->file_path ? existing->file_path : ""); - } - if (c == 0) { - c = existing->start_line - start_line; - } - if (c == 0) { - c = strcmp(existing->name ? existing->name : "", name ? name : ""); - } - if (c == 0) { - c = strcmp(existing->label ? existing->label : "", label ? label : ""); - } - if (c > 0) { - return existing->id; /* existing entity is the canonical winner */ - } - /* Update in-place. name/properties are strdup'd BEFORE freeing old ones - * (callers may pass existing->name as an argument). label/file_path are - * interned: gb_intern returns a stable pool pointer (idempotent even when - * label == existing->label), so the old value is replaced, never freed. - * When the surviving label/name changes, keep the secondary indexes - * consistent (the old code left the node listed under its ORIGINAL - * label/name — cbm_gbuf_find_by_label/name then missed or mis-listed it, - * which is how the flickering Function set reached the semantic pass). */ - char *new_name = dup_or_empty_json(name); - char *new_props = properties_json ? dup_or_empty_json(properties_json) : NULL; - const char *new_label_interned = gb_intern(gb, label); - bool label_changed = !existing->label || !new_label_interned || - strcmp(existing->label, new_label_interned) != 0; - bool name_changed = !existing->name || !new_name || strcmp(existing->name, new_name) != 0; - if (label_changed) { - remove_node_from_ptr_array( - cbm_ht_get(gb->nodes_by_label, existing->label ? existing->label : ""), - existing->id); - } - if (name_changed) { - remove_node_from_ptr_array( - cbm_ht_get(gb->nodes_by_name, existing->name ? existing->name : ""), existing->id); - } - existing->label = (char *)new_label_interned; - free(existing->name); - existing->name = new_name; - existing->file_path = (char *)gb_intern(gb, file_path); - existing->start_line = start_line; - existing->end_line = end_line; - if (new_props) { - free(existing->properties_json); - existing->properties_json = new_props; - } - if (label_changed) { - node_ptr_array_t *by_label = get_or_create_node_array( - gb->nodes_by_label, existing->label ? existing->label : ""); - cbm_da_push(by_label, (const cbm_gbuf_node_t *)existing); - } - if (name_changed) { - node_ptr_array_t *by_name = - get_or_create_node_array(gb->nodes_by_name, existing->name ? existing->name : ""); - cbm_da_push(by_name, (const cbm_gbuf_node_t *)existing); - } - return existing->id; - } - - /* Heap-allocate a new node (pointer stays stable across array growth) */ - cbm_gbuf_node_t *node = calloc(CBM_ALLOC_ONE, sizeof(cbm_gbuf_node_t)); - if (!node) { - return 0; - } - - int64_t id = alloc_next_id(gb); - node->id = id; - node->label = (char *)gb_intern(gb, label); - node->name = dup_or_empty_json(name); - node->qualified_name = dup_or_empty_json(qualified_name); - node->file_path = (char *)gb_intern(gb, file_path); - node->start_line = start_line; - node->end_line = end_line; - node->properties_json = dup_or_empty_json(properties_json); - - /* Store pointer in array and register in all indexes */ - cbm_da_push(&gb->nodes, node); - register_node_in_indexes(gb, node); - - return id; -} - -const cbm_gbuf_node_t *cbm_gbuf_find_by_qn(const cbm_gbuf_t *gb, const char *qn) { - if (!gb || !qn) { - return NULL; - } - return cbm_ht_get(gb->node_by_qn, qn); -} - -bool cbm_gbuf_set_node_props(cbm_gbuf_t *gb, const char *qualified_name, - const char *properties_json) { - if (!gb || !qualified_name || !properties_json) { - return false; - } - cbm_gbuf_node_t *node = cbm_ht_get(gb->node_by_qn, qualified_name); - if (!node) { - return false; - } - char *new_props = dup_or_empty_json(properties_json); - if (!new_props) { - return false; - } - free(node->properties_json); - node->properties_json = new_props; - return true; -} - -const cbm_gbuf_node_t *cbm_gbuf_find_by_id(const cbm_gbuf_t *gb, int64_t id) { - if (!gb || !gb->by_id || id < 0 || id >= gb->by_id_cap) { - return NULL; - } - return gb->by_id[id]; -} - -int cbm_gbuf_find_by_label(const cbm_gbuf_t *gb, const char *label, const cbm_gbuf_node_t ***out, - int *count) { - if (!gb || !out || !count) { - return CBM_NOT_FOUND; - } - node_ptr_array_t *arr = cbm_ht_get(gb->nodes_by_label, label ? label : ""); - if (arr && arr->count > 0) { - *out = arr->items; - *count = arr->count; - } else { - *out = NULL; - *count = 0; - } - return 0; -} - -int cbm_gbuf_find_by_name(const cbm_gbuf_t *gb, const char *name, const cbm_gbuf_node_t ***out, - int *count) { - if (!gb || !out || !count) { - return CBM_NOT_FOUND; - } - node_ptr_array_t *arr = cbm_ht_get(gb->nodes_by_name, name ? name : ""); - if (arr && arr->count > 0) { - *out = arr->items; - *count = arr->count; - } else { - *out = NULL; - *count = 0; - } - return 0; -} - -int cbm_gbuf_node_count(const cbm_gbuf_t *gb) { - /* Use QN hash table count since it's authoritative (handles deletes) */ - return gb ? (int)cbm_ht_count(gb->node_by_qn) : 0; -} - -int cbm_gbuf_delete_by_label(cbm_gbuf_t *gb, const char *label) { - if (!gb || !label) { - return CBM_NOT_FOUND; - } - - node_ptr_array_t *arr = cbm_ht_get(gb->nodes_by_label, label); - if (!arr || arr->count == 0) { - return 0; - } - - /* Build hash set of deleted node IDs for O(1) lookup */ - CBMHashTable *deleted_set = cbm_ht_create(arr->count); - for (int i = 0; i < arr->count; i++) { - const cbm_gbuf_node_t *n = arr->items[i]; - - char id_buf[CBM_SZ_32]; - make_id_key(id_buf, sizeof(id_buf), n->id); - cbm_ht_set(deleted_set, strdup(id_buf), cbm_intptr_to_ptr(SKIP_ONE)); - - /* Remove from primary indexes */ - cbm_ht_delete(gb->node_by_qn, n->qualified_name); - if (n->id >= 0 && n->id < gb->by_id_cap) { - gb->by_id[n->id] = NULL; - } - } - - /* Clear the label array */ - cbm_da_clear(arr); - - /* Cascade-delete edges referencing deleted nodes */ - cascade_delete_edges(gb, deleted_set); - - cbm_ht_foreach(deleted_set, free_key_only, NULL); - cbm_ht_free(deleted_set); - return 0; -} - -int cbm_gbuf_delete_by_file(cbm_gbuf_t *gb, const char *file_path) { - if (!gb || !file_path) { - return CBM_NOT_FOUND; - } - - /* Collect IDs of nodes in this file */ - CBMHashTable *deleted_set = cbm_ht_create(CBM_SZ_64); - int deleted_count = 0; - int scanned = 0; - - for (int i = 0; i < gb->nodes.count; i++) { - cbm_gbuf_node_t *n = gb->nodes.items[i]; - scanned++; - if (!n->file_path || strcmp(n->file_path, file_path) != 0) { - continue; - } - if (!n->qualified_name || !cbm_ht_get(gb->node_by_qn, n->qualified_name)) { - continue; - } - - char id_buf[CBM_SZ_32]; - make_id_key(id_buf, sizeof(id_buf), n->id); - cbm_ht_set(deleted_set, strdup(id_buf), cbm_intptr_to_ptr(SKIP_ONE)); - - /* Remove from secondary indexes */ - remove_node_from_ptr_array(cbm_ht_get(gb->nodes_by_label, n->label), n->id); - remove_node_from_ptr_array(cbm_ht_get(gb->nodes_by_name, n->name), n->id); - - /* Remove from primary indexes */ - cbm_ht_delete(gb->node_by_qn, n->qualified_name); - if (n->id >= 0 && n->id < gb->by_id_cap) { - gb->by_id[n->id] = NULL; - } - - /* NULL out QN so dump's liveness check (cbm_ht_get by QN) fails - * even if a new node with the same QN is inserted later via merge. */ - free(n->qualified_name); - n->qualified_name = NULL; - deleted_count++; - } - - if (deleted_count == 0) { - cbm_ht_free(deleted_set); - return 0; - } - - /* Cascade-delete edges referencing deleted nodes */ - cascade_delete_edges(gb, deleted_set); - - cbm_ht_foreach(deleted_set, free_key_only, NULL); - cbm_ht_free(deleted_set); - { - char s_buf[CBM_SZ_16]; - char d_buf[CBM_SZ_16]; - snprintf(s_buf, sizeof(s_buf), "%d", scanned); - snprintf(d_buf, sizeof(d_buf), "%d", deleted_count); - cbm_log_info("gbuf.delete_by_file", "file", file_path, "scanned", s_buf, "deleted", d_buf); - } - return deleted_count; -} - -static bool gbuf_folder_matches_affected_path(const char *folder_path, const char *affected_path) { - if (!folder_path || !folder_path[0] || !affected_path) { - return false; - } - size_t folder_len = strlen(folder_path); - return strncmp(folder_path, affected_path, folder_len) == 0 && - (affected_path[folder_len] == '\0' || affected_path[folder_len] == '/'); -} - -static int gbuf_delete_empty_folders_impl(cbm_gbuf_t *gb, const char *const *affected_paths, - int affected_count, bool scoped) { - if (!gb) { - return CBM_NOT_FOUND; - } - int removed = 0; - bool changed; - do { - changed = false; - const cbm_gbuf_node_t **folders = NULL; - int count = 0; - if (cbm_gbuf_find_by_label(gb, "Folder", &folders, &count) != 0) { - break; - } - for (int i = 0; i < count; i++) { - const cbm_gbuf_node_t *folder = folders[i]; - if (!folder || !folder->file_path || !folder->qualified_name) { - continue; - } - if (scoped) { - bool affected = false; - for (int path_i = 0; path_i < affected_count; path_i++) { - if (gbuf_folder_matches_affected_path(folder->file_path, affected_paths[path_i])) { - affected = true; - break; - } - } - if (!affected) { - continue; - } - } - const cbm_gbuf_edge_t **edges = NULL; - int edge_count = 0; - cbm_gbuf_find_edges_by_source_type(gb, folder->id, "CONTAINS_FILE", &edges, - &edge_count); - if (edge_count > 0) { - continue; - } - cbm_gbuf_find_edges_by_source_type(gb, folder->id, "CONTAINS_FOLDER", &edges, - &edge_count); - if (edge_count > 0) { - continue; - } - cbm_gbuf_delete_by_file(gb, folder->file_path); - removed++; - changed = true; - break; - } - } while (changed); - return removed; -} - -int cbm_gbuf_delete_empty_folders(cbm_gbuf_t *gb) { - return gbuf_delete_empty_folders_impl(gb, NULL, 0, false); -} - -int cbm_gbuf_delete_empty_folders_under(cbm_gbuf_t *gb, const char *const *affected_paths, - int affected_count) { - if (!affected_paths || affected_count <= 0) { - return 0; - } - return gbuf_delete_empty_folders_impl(gb, affected_paths, affected_count, true); -} - -void cbm_gbuf_foreach_node(const cbm_gbuf_t *gb, cbm_gbuf_node_visitor_fn fn, void *userdata) { - if (!gb || !fn) { - return; - } - for (int i = 0; i < gb->nodes.count; i++) { - const cbm_gbuf_node_t *n = gb->nodes.items[i]; - if (n->qualified_name && cbm_ht_get(gb->node_by_qn, n->qualified_name)) { - fn(n, userdata); - } - } -} - -void cbm_gbuf_foreach_edge(const cbm_gbuf_t *gb, cbm_gbuf_edge_visitor_fn fn, void *userdata) { - if (!gb || !fn) { - return; - } - for (int i = 0; i < gb->edges.count; i++) { - fn(gb->edges.items[i], userdata); - } -} - -/* ── Edge operations ─────────────────────────────────────────────── */ - -/* "confidence": from an edge property blob; -1 when absent or - * unreadable, so a blob that states a confidence outranks one that does not. - * strtod answers 0.0 for text it cannot read ("confidence":null), and 0.0 is a - * real confidence, so an end pointer that never moved means absent. */ -static double edge_properties_confidence(const char *props_json) { - static const char conf_key[] = "\"confidence\":"; - const char *p = props_json ? strstr(props_json, conf_key) : NULL; - if (!p) { - return -1.0; - } - const char *value = p + sizeof(conf_key) - SKIP_ONE; - char *end = NULL; - double conf = strtod(value, &end); - return end == value ? -1.0 : conf; -} - -/* A total order over the two candidates, so the result does not depend on - * arrival order: higher confidence wins (an LSP-resolved call outranks a - * textual match, upstream d08fccd9), then the smaller blob. */ -static bool edge_properties_should_replace(const char *existing, const char *candidate) { - if (!candidate || strcmp(candidate, "{}") == 0) { - return false; - } - if (!existing || strcmp(existing, "{}") == 0) { - return true; - } - double cand_conf = edge_properties_confidence(candidate); - double cur_conf = edge_properties_confidence(existing); - if (cand_conf != cur_conf) { - return cand_conf > cur_conf; - } - return strcmp(candidate, existing) < 0; -} - -int64_t cbm_gbuf_insert_edge(cbm_gbuf_t *gb, int64_t source_id, int64_t target_id, const char *type, - const char *properties_json) { - if (!gb || !type) { - return 0; - } - - /* Check for dedup */ - char key[EDGE_KEY_BUF]; - make_edge_key(key, sizeof(key), source_id, target_id, type, properties_json); - - cbm_gbuf_edge_t *existing = cbm_ht_get(gb->edge_by_key, key); - if (existing) { - /* Pick one canonical value so parallel worker merge order cannot - * change the retained properties for a deduplicated edge. */ - if (edge_properties_should_replace(existing->properties_json, properties_json)) { - char *replacement = dup_or_empty_json(properties_json); - if (replacement) { - free(existing->properties_json); - existing->properties_json = replacement; - } - } - return existing->id; - } - - /* Heap-allocate a new edge (pointer stays stable) */ - cbm_gbuf_edge_t *edge = calloc(CBM_ALLOC_ONE, sizeof(cbm_gbuf_edge_t)); - if (!edge) { - return 0; - } - - int64_t id = alloc_next_id(gb); - edge->id = id; - edge->source_id = source_id; - edge->target_id = target_id; - edge->type = (char *)gb_intern(gb, type); - edge->properties_json = dup_or_empty_json(properties_json); - - /* Store pointer in array */ - cbm_da_push(&gb->edges, edge); - - /* Dedup index */ - cbm_ht_set(gb->edge_by_key, strdup(key), edge); - - /* Secondary indexes */ - register_edge_in_indexes(gb, edge); - - return id; -} - -int cbm_gbuf_find_edges_by_source_type(const cbm_gbuf_t *gb, int64_t source_id, const char *type, - const cbm_gbuf_edge_t ***out, int *count) { - if (!gb || !out || !count) { - return CBM_NOT_FOUND; - } - char key[EDGE_KEY_BUF]; - make_src_type_key(key, sizeof(key), source_id, type); - edge_ptr_array_t *arr = cbm_ht_get(gb->edges_by_source_type, key); - if (arr && arr->count > 0) { - *out = arr->items; - *count = arr->count; - } else { - *out = NULL; - *count = 0; - } - return 0; -} - -int cbm_gbuf_find_edges_by_target_type(const cbm_gbuf_t *gb, int64_t target_id, const char *type, - const cbm_gbuf_edge_t ***out, int *count) { - if (!gb || !out || !count) { - return CBM_NOT_FOUND; - } - char key[EDGE_KEY_BUF]; - make_src_type_key(key, sizeof(key), target_id, type); - edge_ptr_array_t *arr = cbm_ht_get(gb->edges_by_target_type, key); - if (arr && arr->count > 0) { - *out = arr->items; - *count = arr->count; - } else { - *out = NULL; - *count = 0; - } - return 0; -} - -int cbm_gbuf_find_edges_by_type(const cbm_gbuf_t *gb, const char *type, - const cbm_gbuf_edge_t ***out, int *count) { - if (!gb || !out || !count) { - return CBM_NOT_FOUND; - } - edge_ptr_array_t *arr = cbm_ht_get(gb->edges_by_type, type); - if (arr && arr->count > 0) { - *out = arr->items; - *count = arr->count; - } else { - *out = NULL; - *count = 0; - } - return 0; -} - -int cbm_gbuf_edge_count(const cbm_gbuf_t *gb) { - return gb ? gb->edges.count : 0; -} - -int cbm_gbuf_edge_count_by_type(const cbm_gbuf_t *gb, const char *type) { - if (!gb || !type) { - return 0; - } - edge_ptr_array_t *arr = cbm_ht_get(gb->edges_by_type, type); - return arr ? arr->count : 0; -} - -int cbm_gbuf_delete_edges_by_type(cbm_gbuf_t *gb, const char *type) { - if (!gb || !type) { - return CBM_NOT_FOUND; - } - - /* Remove edges of the given type from array and dedup index */ - int write_idx = 0; - for (int i = 0; i < gb->edges.count; i++) { - cbm_gbuf_edge_t *e = gb->edges.items[i]; - if (strcmp(e->type, type) == 0) { - char key[EDGE_KEY_BUF]; - make_edge_key(key, sizeof(key), e->source_id, e->target_id, e->type, - e->properties_json); - const char *ekey = cbm_ht_get_key(gb->edge_by_key, key); - cbm_ht_delete(gb->edge_by_key, key); - free((void *)ekey); - free_edge_strings(e); - free(e); - } else { - gb->edges.items[write_idx++] = gb->edges.items[i]; - } - } - gb->edges.count = write_idx; - - /* Rebuild edge secondary indexes */ - rebuild_edge_secondary_indexes(gb); - - return 0; -} - -int cbm_gbuf_delete_edges_by_source_type(cbm_gbuf_t *gb, int64_t source_id, const char *type) { - if (!gb || !type) { - return CBM_NOT_FOUND; - } - int write_idx = 0; - for (int i = 0; i < gb->edges.count; i++) { - cbm_gbuf_edge_t *e = gb->edges.items[i]; - if (e->source_id == source_id && strcmp(e->type, type) == 0) { - unindex_edge(gb, e); - free_edge_strings(e); - free(e); - } else { - gb->edges.items[write_idx++] = e; - } - } - gb->edges.count = write_idx; - rebuild_edge_secondary_indexes(gb); - return 0; -} - -int cbm_gbuf_delete_edges_by_type_unless(cbm_gbuf_t *gb, const char *type, - cbm_gbuf_edge_keep_fn keep, void *userdata) { - if (!gb || !type || !keep) { - return CBM_NOT_FOUND; - } - int write_idx = 0; - for (int i = 0; i < gb->edges.count; i++) { - cbm_gbuf_edge_t *e = gb->edges.items[i]; - if (strcmp(e->type, type) == 0 && !keep(gb, e, userdata)) { - unindex_edge(gb, e); - free_edge_strings(e); - free(e); - } else { - gb->edges.items[write_idx++] = e; - } - } - if (write_idx != gb->edges.count) { - gb->edges.count = write_idx; - rebuild_edge_secondary_indexes(gb); - } - return 0; -} - -/* ── Merge ───────────────────────────────────────────────────────── */ - -/* Free remap hash table entries (key = heap string, value = heap int64_t*) */ -static void free_remap_entry(const char *key, void *val, void *ud) { - (void)ud; - free((void *)key); - free(val); -} - -/* Handle QN collision: update dst node fields (src wins), record remap if IDs differ. - * label/file_path are re-interned into dst's pool (sn's pointers belong to src). */ -static void merge_update_existing(cbm_gbuf_t *dst, cbm_gbuf_node_t *existing, - const cbm_gbuf_node_t *sn, CBMHashTable **remap) { - /* Same guard as cbm_gbuf_upsert_node: a per-file "Module" def coming from a - * worker-local gbuf must not touch the structural directory node ("Project" - * root or "Folder") that shares its QN in a directory-based-module language - * (Java/Go). pass_structure seeds Folder/Project nodes on the MAIN gbuf - * before the parallel extract, so every worker's always-emitted Module def - * for that package collides here; unconditional "src wins" relabelled the - * directory node to Module and set its file_path to whichever worker merged - * LAST — the nondeterministic USAGE-source misattribution of #787. Keep the - * structural node intact; the ID remap below still redirects the worker's - * edges onto the canonical node. */ - bool module_on_container = - existing->label && sn->label && strcmp(sn->label, "Module") == 0 && - (strcmp(existing->label, "Project") == 0 || strcmp(existing->label, "Folder") == 0); - if (!module_on_container) { - /* Canonical collision winner (determinism) — mirrors - * cbm_gbuf_upsert_node exactly. Distinct source entities can share a - * QN (C: struct/function/macro with one name); unconditional "src - * wins" made the survivor depend on worker merge order, flickering - * the node set (and every downstream consumer) run to run. Winner = - * smallest file_path, then LARGEST start_line, then largest - * name/label — one total order, commutative, scheduling-free; a full - * tie is the same entity → refresh from src. A definition beats a - * macro ahead of all of these (gb_qn_claim_rank). */ - int c = gb_qn_claim_rank(existing->label) - gb_qn_claim_rank(sn->label); - if (c == 0) { - c = strcmp(sn->file_path ? sn->file_path : "", - existing->file_path ? existing->file_path : ""); - } - if (c == 0) { - c = existing->start_line - sn->start_line; - } - if (c == 0) { - c = strcmp(existing->name ? existing->name : "", sn->name ? sn->name : ""); - } - if (c == 0) { - c = strcmp(existing->label ? existing->label : "", sn->label ? sn->label : ""); - } - bool sn_wins = c <= 0; - if (sn_wins) { - /* Keep the secondary indexes consistent when the surviving - * label/name changes (the old code left the node listed under its - * original label/name, so find_by_label/name mis-listed it). */ - const char *new_label = gb_intern(dst, sn->label); - bool label_changed = - !existing->label || !new_label || strcmp(existing->label, new_label) != 0; - bool name_changed = - !existing->name || !sn->name || strcmp(existing->name, sn->name) != 0; - if (label_changed) { - remove_node_from_ptr_array( - cbm_ht_get(dst->nodes_by_label, existing->label ? existing->label : ""), - existing->id); - } - if (name_changed) { - remove_node_from_ptr_array( - cbm_ht_get(dst->nodes_by_name, existing->name ? existing->name : ""), - existing->id); - } - existing->label = (char *)new_label; - free(existing->name); - existing->name = dup_or_empty_json(sn->name); - existing->file_path = (char *)gb_intern(dst, sn->file_path); - existing->start_line = sn->start_line; - existing->end_line = sn->end_line; - if (sn->properties_json) { - free(existing->properties_json); - existing->properties_json = dup_or_empty_json(sn->properties_json); - } - if (label_changed) { - node_ptr_array_t *by_label = get_or_create_node_array( - dst->nodes_by_label, existing->label ? existing->label : ""); - cbm_da_push(by_label, (const cbm_gbuf_node_t *)existing); - } - if (name_changed) { - node_ptr_array_t *by_name = get_or_create_node_array( - dst->nodes_by_name, existing->name ? existing->name : ""); - cbm_da_push(by_name, (const cbm_gbuf_node_t *)existing); - } - } - } - - if (sn->id != existing->id) { - if (!*remap) { - *remap = cbm_ht_create(CBM_SZ_32); - } - char key[CBM_SZ_32]; - make_id_key(key, sizeof(key), sn->id); - int64_t *val = malloc(sizeof(int64_t)); - *val = existing->id; - cbm_ht_set(*remap, strdup(key), val); - } -} - -/* Copy a non-colliding src node into dst with its original ID. */ -static void merge_copy_new_node(cbm_gbuf_t *dst, const cbm_gbuf_node_t *sn) { - cbm_gbuf_node_t *node = calloc(CBM_ALLOC_ONE, sizeof(cbm_gbuf_node_t)); - if (!node) { - return; - } - - node->id = sn->id; - node->label = (char *)gb_intern(dst, sn->label); - node->name = dup_or_empty_json(sn->name); - node->qualified_name = dup_or_empty_json(sn->qualified_name); - node->file_path = (char *)gb_intern(dst, sn->file_path); - node->start_line = sn->start_line; - node->end_line = sn->end_line; - node->properties_json = dup_or_empty_json(sn->properties_json); - - cbm_da_push(&dst->nodes, node); - register_node_in_indexes(dst, node); - - if (node->id >= dst->next_id) { - dst->next_id = node->id + SKIP_ONE; - } -} - -/* Remap edge IDs using the collision remap table and insert into dst. */ -static void merge_remap_edges(cbm_gbuf_t *dst, cbm_gbuf_t *src, CBMHashTable *remap) { - for (int i = 0; i < src->edges.count; i++) { - cbm_gbuf_edge_t *se = src->edges.items[i]; - - int64_t new_src = se->source_id; - int64_t new_tgt = se->target_id; - - if (remap) { - char key[CBM_SZ_32]; - make_id_key(key, sizeof(key), se->source_id); - int64_t *remapped = cbm_ht_get(remap, key); - if (remapped) { - new_src = *remapped; - } - - make_id_key(key, sizeof(key), se->target_id); - remapped = cbm_ht_get(remap, key); - if (remapped) { - new_tgt = *remapped; - } - } - - cbm_gbuf_insert_edge(dst, new_src, new_tgt, se->type, se->properties_json); - } -} - -int cbm_gbuf_merge(cbm_gbuf_t *dst, cbm_gbuf_t *src) { - if (!dst || !src) { - return CBM_NOT_FOUND; - } - if (src->nodes.count == 0 && src->edges.count == 0) { - return 0; - } - - /* ID remap for QN-colliding nodes: "src_id" → (int64_t*) dst_id. - * Only populated when a src node's QN already exists in dst. */ - CBMHashTable *remap = NULL; - - for (int i = 0; i < src->nodes.count; i++) { - cbm_gbuf_node_t *sn = src->nodes.items[i]; - if (!sn->qualified_name) { - continue; - } - - /* Skip nodes deleted from QN index */ - if (!cbm_ht_get(src->node_by_qn, sn->qualified_name)) { - continue; - } - - cbm_gbuf_node_t *existing = cbm_ht_get(dst->node_by_qn, sn->qualified_name); - if (existing) { - merge_update_existing(dst, existing, sn, &remap); - } else { - merge_copy_new_node(dst, sn); - } - } - - /* Merge edges with optional ID remapping */ - merge_remap_edges(dst, src, remap); - - if (remap) { - cbm_ht_foreach(remap, free_remap_entry, NULL); - cbm_ht_free(remap); - } - - return 0; -} - -/* ── Dump / Flush ────────────────────────────────────────────────── */ - -/* Extract a string property from a properties JSON string. - * Returns heap-allocated string or NULL. Caller must free. - * Parses real JSON: the dump writer feeds these values into indexes whose - * backing columns are GENERATED AS json_extract(properties,'$.'). - * Naive byte slicing returned the ESCAPED text (and cut at embedded \\") - * while json_extract yields the unescaped value — the mismatch left rows - * "missing from index idx_edges_url_path" under PRAGMA integrity_check. - * key_quoted ("\"key\"") is a fast pre-filter to skip the JSON parse. */ -static char *extract_prop_string(const char *props, const char *key_quoted, const char *key) { - if (!props || !strstr(props, key_quoted)) { - return NULL; - } - yyjson_doc *doc = yyjson_read(props, strlen(props), 0); - if (!doc) { - return NULL; - } - char *out = NULL; - yyjson_val *v = yyjson_obj_get(yyjson_doc_get_root(doc), key); - if (v && yyjson_is_str(v)) { - const char *sv = yyjson_get_str(v); - out = cbm_strndup(sv, strlen(sv)); - } - yyjson_doc_free(doc); - return out; -} - -static char *extract_url_path(const char *props) { - return extract_prop_string(props, "\"url_path\"", "url_path"); -} - -/* local_name feeds the hand-built sqlite_autoindex_edges_1 — its backing - * column local_name_gen is GENERATED only for IMPORTS edges (#768). */ -static char *extract_local_name(const char *props) { - return extract_prop_string(props, "\"local_name\"", "local_name"); -} - -/* Remap a temp edge ID to its final sequential ID, or 0 if out of range. */ -static int64_t remap_id(const int64_t *temp_to_final, int64_t max_temp_id, int64_t temp_id) { - return (temp_id < max_temp_id) ? temp_to_final[temp_id] : 0; -} - -/* Build dump-ready node array with sequential IDs. Populates temp_to_final mapping. */ -static int cmp_dump_vectors_by_id(const void *a, const void *b) { - int64_t da = ((const CBMDumpVector *)a)->node_id; - int64_t db = ((const CBMDumpVector *)b)->node_id; - return (da > db) - (da < db); -} - -static CBMDumpNode *build_dump_nodes(cbm_gbuf_t *gb, int live_count, int64_t *temp_to_final, - int64_t max_temp_id, int *out_count, - cbm_gbuf_node_t ***src_out) { - size_t cap = (size_t)(live_count > 0 ? live_count : SKIP_ONE); - CBMDumpNode *dump_nodes = malloc(cap * sizeof(CBMDumpNode)); - /* Parallel gbuf-node pointers so a streamed partition can free its heavy - * properties_json after the rows are persisted. NULL on OOM disables the - * per-partition free (the dump still succeeds). */ - cbm_gbuf_node_t **src = malloc(cap * sizeof(cbm_gbuf_node_t *)); - int idx = 0; - - for (int i = 0; i < gb->nodes.count; i++) { - cbm_gbuf_node_t *n = gb->nodes.items[i]; - if (!n->qualified_name || !cbm_ht_get(gb->node_by_qn, n->qualified_name)) { - continue; - } - - int64_t final_id = idx + SKIP_ONE; /* 1-based sequential */ - if (n->id < max_temp_id) { - temp_to_final[n->id] = final_id; - } - - const char *fp = n->file_path ? n->file_path : ""; - const char *props = n->properties_json ? n->properties_json : "{}"; - dump_nodes[idx] = (CBMDumpNode){ - .id = final_id, - .project = gb->project, - .label = n->label, - .name = n->name, - .qualified_name = n->qualified_name, - .file_path = fp, - .start_line = n->start_line, - .end_line = n->end_line, - .properties = props, - }; - if (src) { - src[idx] = n; - } - idx++; - } - - *out_count = idx; - *src_out = src; - return dump_nodes; -} - -/* Build dump-ready edge array with remapped IDs. Returns url_paths and - * local_names (heap string arrays owned by the caller) via out params. */ -static CBMDumpEdge *build_dump_edges(cbm_gbuf_t *gb, const int64_t *temp_to_final, - int64_t max_temp_id, int *out_count, char ***out_url_paths, - char ***out_local_names) { - /* Count valid edges (both endpoints resolved) */ - int valid_edges = 0; - for (int i = 0; i < gb->edges.count; i++) { - cbm_gbuf_edge_t *e = gb->edges.items[i]; - if (remap_id(temp_to_final, max_temp_id, e->source_id) > 0 && - remap_id(temp_to_final, max_temp_id, e->target_id) > 0) { - valid_edges++; - } - } - - CBMDumpEdge *dump_edges = - malloc((size_t)(valid_edges > 0 ? valid_edges : SKIP_ONE) * sizeof(CBMDumpEdge)); - char **url_paths = calloc((size_t)(valid_edges > 0 ? valid_edges : SKIP_ONE), sizeof(char *)); - char **local_names = calloc((size_t)(valid_edges > 0 ? valid_edges : SKIP_ONE), sizeof(char *)); - int idx = 0; - - for (int i = 0; i < gb->edges.count; i++) { - cbm_gbuf_edge_t *e = gb->edges.items[i]; - int64_t src = remap_id(temp_to_final, max_temp_id, e->source_id); - int64_t tgt = remap_id(temp_to_final, max_temp_id, e->target_id); - if (src == 0 || tgt == 0) { - continue; - } - - char *url_path = extract_url_path(e->properties_json); - url_paths[idx] = url_path; - - /* IMPORTS only — mirrors the local_name_gen CASE in the edges DDL. */ - char *local_name = (e->type && strcmp(e->type, "IMPORTS") == 0) - ? extract_local_name(e->properties_json) - : NULL; - local_names[idx] = local_name; - - const char *props = e->properties_json ? e->properties_json : "{}"; - dump_edges[idx] = (CBMDumpEdge){ - .id = idx + SKIP_ONE, - .project = gb->project, - .source_id = src, - .target_id = tgt, - .type = e->type, - .properties = props, - .url_path = url_path ? url_path : "", - .local_name = local_name ? local_name : "", - }; - idx++; - } - - *out_count = idx; - *out_url_paths = url_paths; - *out_local_names = local_names; - return dump_edges; -} - -/* Remap vector node IDs through temp_to_final, sort by ID, deduplicate. */ -static void remap_sort_dedup_vectors(cbm_gbuf_t *gb, const int64_t *temp_to_final, - int64_t max_temp_id) { - int remapped = 0; - int dropped = 0; - for (int i = 0; i < gb->dump_vector_count; i++) { - int64_t old_id = gb->dump_vectors[i].node_id; - int64_t new_id = (old_id > 0 && old_id < max_temp_id) ? temp_to_final[old_id] : 0; - if (new_id > 0) { - gb->dump_vectors[remapped] = gb->dump_vectors[i]; - gb->dump_vectors[remapped].node_id = new_id; - remapped++; - } else { - dropped++; - } - } - if (dropped > 0) { - char r_buf[CBM_SZ_16]; - char d_buf[CBM_SZ_16]; - snprintf(r_buf, sizeof(r_buf), "%d", remapped); - snprintf(d_buf, sizeof(d_buf), "%d", dropped); - cbm_log_info("dump.vectors.remap", "remapped", r_buf, "dropped", d_buf); - } - gb->dump_vector_count = remapped; - - if (gb->dump_vector_count >= GB_MIN_FOR_DEDUP) { - qsort(gb->dump_vectors, (size_t)gb->dump_vector_count, sizeof(CBMDumpVector), - cmp_dump_vectors_by_id); - int deduped = 0; - for (int i = 0; i < gb->dump_vector_count; i++) { - if (i + GB_DEDUP_LOOKAHEAD < gb->dump_vector_count && - gb->dump_vectors[i].node_id == gb->dump_vectors[i + GB_DEDUP_LOOKAHEAD].node_id) { - continue; - } - gb->dump_vectors[deduped++] = gb->dump_vectors[i]; - } - gb->dump_vector_count = deduped; - } -} - -static void log_dump_summary(int node_count, int edge_count) { - char b1[CBM_SZ_16]; - char b2[CBM_SZ_16]; - snprintf(b1, sizeof(b1), "%d", node_count); - snprintf(b2, sizeof(b2), "%d", edge_count); - cbm_log_info("gbuf.dump", "nodes", b1, "edges", b2); -} - -static void free_retained_dump_edges(CBMDumpEdge *edges, int edge_count) { - if (!edges) return; - for (int i = 0; i < edge_count; i++) { - free((void *)edges[i].url_path); - free((void *)edges[i].local_name); - } - free(edges); -} - -static int retain_dump_edge_strings(CBMDumpEdge *edges, int edge_count) { - for (int i = 0; i < edge_count; i++) { - char *owned_url_path = strdup(edges[i].url_path ? edges[i].url_path : ""); - char *owned_local_name = strdup(edges[i].local_name ? edges[i].local_name : ""); - if (!owned_url_path || !owned_local_name) { - free(owned_url_path); - free(owned_local_name); - for (int j = 0; j < i; j++) { - free((void *)edges[j].url_path); - free((void *)edges[j].local_name); - } - return -1; - } - edges[i].url_path = owned_url_path; - edges[i].local_name = owned_local_name; - } - return 0; -} - -static void free_dump_resources(char **url_paths, char **local_names, int edge_count, - CBMDumpEdge *dump_edges, CBMDumpNode *dump_nodes, - int64_t *temp_to_final) { - for (int i = 0; i < edge_count; i++) { - free(url_paths[i]); - free(local_names[i]); - } - free(url_paths); - free(local_names); - free(dump_edges); - free(dump_nodes); - free(temp_to_final); -} - -static int count_live_nodes(cbm_gbuf_t *gb) { - int count = 0; - for (int i = 0; i < gb->nodes.count; i++) { - cbm_gbuf_node_t *n = gb->nodes.items[i]; - if (n->qualified_name && cbm_ht_get(gb->node_by_qn, n->qualified_name)) { - count++; - } - } - return count; -} - -static void generate_iso_timestamp(char *buf, size_t buf_size) { - time_t now = time(NULL); - struct tm tm_buf; - struct tm *tm_val = cbm_gmtime_r(&now, &tm_buf); - if (strftime(buf, buf_size, "%Y-%m-%dT%H:%M:%SZ", tm_val) == 0) { - snprintf(buf, buf_size, "1970-01-01T00:00:00Z"); - } -} - -/* Remap+sort+dedup vectors to the final node ids. */ -static void release_and_remap_vectors(cbm_gbuf_t *gb, const int64_t *temp_to_final, - int64_t max_temp_id) { - CBM_PROF_START(t_vec_remap); - remap_sort_dedup_vectors(gb, temp_to_final, max_temp_id); - CBM_PROF_END_N("dump", "5_vector_remap_sort", t_vec_remap, gb->dump_vector_count); -} - -/* Build the final node, edge and vector arrays published to cbm.zova. */ -static int gbuf_build_dump(cbm_gbuf_t *gb) { - if (!gb) { - return CBM_NOT_FOUND; - } - - CBM_PROF_START(t_count); - int live_count = count_live_nodes(gb); - CBM_PROF_END_N("dump", "1_count_live_nodes", t_count, live_count); - - CBM_PROF_START(t_build_nodes); - int64_t max_temp_id = gb->next_id; - int64_t *temp_to_final = calloc((size_t)max_temp_id, sizeof(int64_t)); - if (!temp_to_final) { - return CBM_NOT_FOUND; - } - - int node_idx = 0; - cbm_gbuf_node_t **src_nodes = NULL; - CBMDumpNode *dump_nodes = - build_dump_nodes(gb, live_count, temp_to_final, max_temp_id, &node_idx, &src_nodes); - CBM_PROF_END_N("dump", "2_build_dump_nodes", t_build_nodes, node_idx); - - /* Release ALL lookup indexes NOW: nothing between here and finalize reads - * them (stream-append uses dump_nodes/src_nodes, build_dump_edges uses - * temp_to_final + gb->edges, the vector remap uses temp_to_final). At - * kernel scale they are ~3.8 GB (hash buckets + key strings + pointer - * arrays); releasing them only AFTER edge building made them coexist with - * every dump-side transient array — the dump-phase RSS peak. */ - CBM_PROF_START(t_release_idx); - release_gbuf_indexes(gb); - CBM_PROF_END("dump", "4_release_gbuf_indexes", t_release_idx); - - generate_iso_timestamp(gb->indexed_at, sizeof(gb->indexed_at)); - - int rc = 0; - int edge_idx = 0; - char **url_paths = NULL; - char **local_names = NULL; - CBM_PROF_START(t_build_edges); - CBMDumpEdge *dump_edges = - build_dump_edges(gb, temp_to_final, max_temp_id, &edge_idx, &url_paths, &local_names); - CBM_PROF_END_N("dump", "3_build_dump_edges", t_build_edges, edge_idx); - release_and_remap_vectors(gb, temp_to_final, max_temp_id); - - if (retain_dump_edge_strings(dump_edges, edge_idx) != 0) { - rc = CBM_NOT_FOUND; - } - - if (rc == 0) { - free(gb->zova_dump_nodes); - free_retained_dump_edges(gb->zova_dump_edges, gb->zova_dump_edge_count); - gb->zova_dump_nodes = dump_nodes; - gb->zova_dump_node_count = node_idx; - gb->zova_dump_edges = dump_edges; - gb->zova_dump_edge_count = edge_idx; - dump_nodes = NULL; - dump_edges = NULL; - } - - log_dump_summary(node_idx, edge_idx); - free_dump_resources(url_paths, local_names, edge_idx, dump_edges, dump_nodes, temp_to_final); - free(src_nodes); - return rc; -} - -int cbm_gbuf_prepare_zova_dump(cbm_gbuf_t *gb) { - int rc = gbuf_build_dump(gb); - if (rc != 0) return rc; - char workspace_id[CBM_ZOVA_WORKSPACE_ID_MAX]; - cbm_zova_workspace_generation_input_t input = { - .root_path = gb->root_path, - .project = gb->project, - .indexed_at = gb->indexed_at, - .model_fingerprint = CBM_ZOVA_MODEL_FINGERPRINT, - .vector_dimensions = CBM_SEM_DIM, - .nodes = gb->zova_dump_nodes, - .node_count = gb->zova_dump_node_count, - .edges = gb->zova_dump_edges, - .edge_count = gb->zova_dump_edge_count, - .node_vectors = gb->dump_vectors, - .node_vector_count = gb->dump_vector_count, - .token_vectors = gb->dump_token_vecs, - .token_vector_count = gb->dump_token_vec_count, - }; - cbm_zova_prepared_view_t *view = NULL; - if (cbm_zova_workspace_id_for_root(gb->root_path, workspace_id, - sizeof(workspace_id)) != 0 || - cbm_zova_prepared_view_build_base(workspace_id, &input, &view) != 0) - return CBM_NOT_FOUND; - cbm_zova_prepared_view_free(gb->zova_prepared_view); - gb->zova_prepared_view = view; - return 0; -} - -int cbm_gbuf_prepare_zova_dump_delta(cbm_gbuf_t *gb) { - return gbuf_build_dump(gb); -} - -int cbm_gbuf_publish_zova_user_database( - cbm_gbuf_t *gb, const cbm_zova_file_hash_input_t *file_hashes, - int file_hash_count, const cbm_zova_project_summary_input_t *project_summary, - cbm_zova_workspace_generation_result_t *out_result) { - if (!gb || !out_result || file_hash_count < 0 || - (file_hash_count > 0 && !file_hashes)) { - return CBM_NOT_FOUND; - } - char user_database_path[CBM_SZ_1K]; - if (cbm_zova_workspace_registry_path(user_database_path, sizeof(user_database_path)) != 0) { - return CBM_NOT_FOUND; - } - char indexed_at[CBM_SZ_64]; - if (gb->indexed_at[0] != '\0') { - snprintf(indexed_at, sizeof(indexed_at), "%s", gb->indexed_at); - } else { - generate_iso_timestamp(indexed_at, sizeof(indexed_at)); - } - cbm_zova_workspace_generation_input_t input = { - .root_path = gb->root_path, - .project = gb->project, - .indexed_at = indexed_at, - .model_fingerprint = CBM_ZOVA_MODEL_FINGERPRINT, - .vector_dimensions = CBM_SEM_DIM, - .nodes = gb->zova_dump_nodes, - .node_count = gb->zova_dump_node_count, - .edges = gb->zova_dump_edges, - .edge_count = gb->zova_dump_edge_count, - .node_vectors = gb->dump_vectors, - .node_vector_count = gb->dump_vector_count, - .token_vectors = gb->dump_token_vecs, - .token_vector_count = gb->dump_token_vec_count, - .file_hashes = file_hashes, - .file_hash_count = file_hash_count, - .project_summary = project_summary ? *project_summary : (cbm_zova_project_summary_input_t){0}, - }; - char workspace_id[CBM_ZOVA_WORKSPACE_ID_MAX]; - cbm_zova_prepared_view_t *view = gb->zova_prepared_view; - bool owned_view = false; - int rc = cbm_zova_workspace_id_for_root(input.root_path, workspace_id, - sizeof(workspace_id)); - if (rc == 0 && !view) { - rc = cbm_zova_prepared_view_build(workspace_id, &input, &view); - owned_view = rc == 0; - } else if (rc == 0) { - rc = cbm_zova_prepared_view_complete( - view, file_hashes, file_hash_count, project_summary); - } - if (rc == 0) - rc = cbm_zova_user_database_publish_prepared_view(user_database_path, view, - out_result); - if (owned_view) cbm_zova_prepared_view_free(view); - return rc; -} - -int cbm_gbuf_publish_zova_user_database_delta( - const cbm_gbuf_t *gb, cbm_zova_workspace_snapshot_t *before, - const cbm_zova_file_hash_input_t *file_hashes, int file_hash_count, - const cbm_zova_project_summary_input_t *project_summary, - cbm_zova_workspace_generation_result_t *out_result) { - if (!gb || !before || !out_result || file_hash_count < 0 || - (file_hash_count > 0 && !file_hashes)) return CBM_NOT_FOUND; - char user_database_path[CBM_SZ_1K]; - if (cbm_zova_workspace_registry_path(user_database_path, sizeof(user_database_path)) != 0) - return CBM_NOT_FOUND; - char indexed_at[CBM_SZ_64]; - if (gb->indexed_at[0] != '\0') snprintf(indexed_at, sizeof(indexed_at), "%s", gb->indexed_at); - else generate_iso_timestamp(indexed_at, sizeof(indexed_at)); - cbm_zova_workspace_generation_input_t input = { - .root_path=gb->root_path,.project=gb->project,.indexed_at=indexed_at, - .model_fingerprint=CBM_ZOVA_MODEL_FINGERPRINT,.vector_dimensions=CBM_SEM_DIM, - .nodes=gb->zova_dump_nodes,.node_count=gb->zova_dump_node_count, - .edges=gb->zova_dump_edges,.edge_count=gb->zova_dump_edge_count, - .node_vectors=gb->dump_vectors,.node_vector_count=gb->dump_vector_count, - .token_vectors=gb->dump_token_vecs,.token_vector_count=gb->dump_token_vec_count, - .file_hashes=file_hashes,.file_hash_count=file_hash_count, - .project_summary=project_summary?*project_summary:(cbm_zova_project_summary_input_t){0}, - }; - char workspace_id[CBM_ZOVA_WORKSPACE_ID_MAX]; - cbm_zova_publish_model_t *model=NULL; - cbm_zova_workspace_delta_t *delta=NULL; - int rc=cbm_zova_workspace_id_for_root(input.root_path,workspace_id,sizeof(workspace_id)); - if(rc==0&&strcmp(workspace_id,before->workspace_id)!=0)rc=-1; - if(rc==0)rc=cbm_zova_publish_model_build(workspace_id,&input,&model); - if (rc == 0 && gb->reuse_zova_semantic_vectors) - rc = cbm_zova_publish_model_reuse_vector_digests( - model, &before->integrity, - CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS | - CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS); - cbm_zova_snapshot_components_t required=CBM_ZOVA_SNAPSHOT_COMPONENT_NONE; - if(rc==0)rc=cbm_zova_workspace_delta_required_components(before,model,&required); - if(rc==0&&required!=CBM_ZOVA_SNAPSHOT_COMPONENT_NONE) - rc=cbm_zova_repository_hydrate_incremental_components( - user_database_path,workspace_id,before->generation,required,before); - if(rc==0)rc=cbm_zova_workspace_delta_build(before,model,&delta); - if(rc==0)rc=cbm_zova_user_database_publish_delta(user_database_path,model,delta,out_result); - cbm_zova_workspace_delta_free(delta); - cbm_zova_publish_model_free(model); - return rc; -} - -int cbm_gbuf_flush_to_store(cbm_gbuf_t *gb, cbm_store_t *store) { - if (!gb || !store) { - return CBM_NOT_FOUND; - } - - /* Upsert project */ - cbm_store_upsert_project(store, gb->project, gb->root_path); - - /* Begin bulk mode */ - cbm_store_begin_bulk(store); - cbm_store_drop_indexes(store); - cbm_store_begin(store); - - /* Delete existing project data */ - cbm_store_delete_edges_by_project(store, gb->project); - cbm_store_delete_nodes_by_project(store, gb->project); - - /* Build temp_id → real_id map. - * Temp IDs start at 1 and are sequential, but can have gaps from edge inserts. - * Use max_id as size. */ - int64_t max_temp_id = gb->next_id; - int64_t *temp_to_real = calloc(max_temp_id, sizeof(int64_t)); - - for (int i = 0; i < gb->nodes.count; i++) { - cbm_gbuf_node_t *n = gb->nodes.items[i]; - - /* Skip if deleted from QN index */ - if (!n->qualified_name || !cbm_ht_get(gb->node_by_qn, n->qualified_name)) { - continue; - } - - cbm_node_t sn = { - .project = gb->project, - .label = n->label, - .name = n->name, - .qualified_name = n->qualified_name, - .file_path = n->file_path, - .start_line = n->start_line, - .end_line = n->end_line, - .properties_json = n->properties_json, - }; - int64_t real_id = cbm_store_upsert_node(store, &sn); - if (real_id > 0 && n->id < max_temp_id) { - temp_to_real[n->id] = real_id; - } - } - - /* Insert all edges with remapped IDs */ - for (int i = 0; i < gb->edges.count; i++) { - cbm_gbuf_edge_t *e = gb->edges.items[i]; - int64_t real_src = (e->source_id < max_temp_id) ? temp_to_real[e->source_id] : 0; - int64_t real_tgt = (e->target_id < max_temp_id) ? temp_to_real[e->target_id] : 0; - if (real_src == 0 || real_tgt == 0) { - continue; - } - - cbm_edge_t se = { - .project = gb->project, - .source_id = real_src, - .target_id = real_tgt, - .type = e->type, - .properties_json = e->properties_json, - }; - cbm_store_insert_edge(store, &se); - } - - cbm_store_commit(store); - cbm_store_create_indexes(store); - cbm_store_end_bulk(store); - - free(temp_to_real); - return 0; -} - -int cbm_gbuf_merge_into_store(cbm_gbuf_t *gb, cbm_store_t *store) { - if (!gb || !store) { - return CBM_NOT_FOUND; - } - - /* Begin bulk mode — no project wipe */ - cbm_store_begin(store); - - /* Build temp_id → real_id map */ - int64_t max_temp_id = gb->next_id; - int64_t *temp_to_real = calloc(max_temp_id, sizeof(int64_t)); - - for (int i = 0; i < gb->nodes.count; i++) { - cbm_gbuf_node_t *n = gb->nodes.items[i]; - - if (!n->qualified_name || !cbm_ht_get(gb->node_by_qn, n->qualified_name)) { - continue; - } - - cbm_node_t sn = { - .project = gb->project, - .label = n->label, - .name = n->name, - .qualified_name = n->qualified_name, - .file_path = n->file_path, - .start_line = n->start_line, - .end_line = n->end_line, - .properties_json = n->properties_json, - }; - int64_t real_id = cbm_store_upsert_node(store, &sn); - if (real_id > 0 && n->id < max_temp_id) { - temp_to_real[n->id] = real_id; - } - } - - for (int i = 0; i < gb->edges.count; i++) { - cbm_gbuf_edge_t *e = gb->edges.items[i]; - int64_t real_src = (e->source_id < max_temp_id) ? temp_to_real[e->source_id] : 0; - int64_t real_tgt = (e->target_id < max_temp_id) ? temp_to_real[e->target_id] : 0; - if (real_src == 0 || real_tgt == 0) { - continue; - } - - cbm_edge_t se = { - .project = gb->project, - .source_id = real_src, - .target_id = real_tgt, - .type = e->type, - .properties_json = e->properties_json, - }; - cbm_store_insert_edge(store, &se); - } - - cbm_store_commit(store); - - free(temp_to_real); - return 0; -} diff --git a/src/graph_buffer/graph_buffer.zig b/src/graph_buffer/graph_buffer.zig new file mode 100644 index 00000000..f59bb994 --- /dev/null +++ b/src/graph_buffer/graph_buffer.zig @@ -0,0 +1,1831 @@ +//! graph_buffer.zig — In-memory graph buffer for pipeline indexing. +//! +//! Implements src/graph_buffer/graph_buffer.h. +//! +//! Uses foundation hash tables for O(1) node lookup by QN and edge dedup. +//! Uses dynamic arrays for ordered iteration and secondary indexes. +//! +//! Memory ownership: each node/edge is individually heap-allocated so that +//! pointers stored in hash tables remain stable when the pointer-array grows. +//! The buffer frees everything in cbm_gbuf_free(). + +const std = @import("std"); +const c = @import("c"); +const interop = @import("../foundation/interop.zig"); + +const Str = interop.Str; +const CStr = interop.CStr; +const Ht = c.CBMHashTable; +const Node = c.cbm_gbuf_node_t; +const Edge = c.cbm_gbuf_edge_t; + +const GB_ERR = -1; +const NOT_FOUND = c.CBM_NOT_FOUND; +const GB_MIN_FOR_DEDUP = 2; // need at least 2 vectors to sort+dedup +const GB_DEDUP_LOOKAHEAD = 1; // compare current with next element + +// ── Internal types ───────────────────────────────────────────────── + +/// Edge key for dedup hash table — composite key as string "srcID:tgtID:type", +/// plus ":local_name" for IMPORTS edges (#768). 256 bytes fit two int64s, the +/// type and a ~200-char local_name verbatim; longer local_names are re-keyed +/// with a hash of the full name in makeEdgeKey (never silently truncated). +const EDGE_KEY_BUF = 256; +const ID_KEY_BUF = 32; + +/// CBM_DYN_ARRAY(T) from foundation/dyn_array.h. +fn DynArray(comptime T: type) type { + return extern struct { + items: ?[*]T, + count: c_int, + cap: c_int, + + const Self = @This(); + + fn slice(self: *const Self) []T { + const items = self.items orelse return &.{}; + return items[0..@intCast(self.count)]; + } + + /// cbm_da_push: grows by 2x when full; drops the item on OOM. + fn push(self: *Self, item: T) void { + if (self.count >= self.cap) { + const new_cap: c_int = if (self.cap != 0) self.cap * 2 else 8; + const grown = c.realloc(@ptrCast(self.items), @as(usize, @intCast(new_cap)) * @sizeOf(T)) orelse return; + self.items = @ptrCast(@alignCast(grown)); + self.cap = new_cap; + } + self.items.?[@intCast(self.count)] = item; + self.count += 1; + } + + /// cbm_da_free. + fn free(self: *Self) void { + c.free(@ptrCast(self.items)); + self.items = null; + self.count = 0; + self.cap = 0; + } + + /// realloc to hold at least n items (cbm_gbuf_reserve). + fn reserve(self: *Self, n: c_int) bool { + if (n <= self.cap) return true; + const grown = c.realloc(@ptrCast(self.items), @as(usize, @intCast(n)) * @sizeOf(T)) orelse return false; + self.items = @ptrCast(@alignCast(grown)); + self.cap = n; + return true; + } + }; +} + +/// Per-type or per-key edge list stored in hash tables as values. +const EdgePtrArray = DynArray(*const Edge); + +/// Per-label or per-name node list. +const NodePtrArray = DynArray(*const Node); + +const Gbuf = extern struct { + project: [*c]u8, + root_path: [*c]u8, + next_id: i64, + shared_ids: ?*std.atomic.Value(i64), // NULL = use next_id, non-NULL = atomic source + + /// Node storage: array of pointers to individually heap-allocated nodes. + /// This ensures pointers stored in hash tables remain valid when the + /// pointer array reallocs (only the pointer array moves, not the nodes). + nodes: DynArray(*Node), + + /// Primary index: QN → Node* + node_by_qn: ?*Ht, + /// Dense id → node array (ids are sequential from allocNextId, shared + /// with edges → holes where edges took ids). Replaces a hash table keyed + /// on STRDUP'D DECIMAL STRINGS of the id — ~0.44 GB of buckets + key + /// strings at kernel scale, plus a snprintf+strdup+hash on every one of + /// the ~18 hot find_by_id call sites. + by_id: ?[*]?*Node, + by_id_cap: i64, + + // Secondary node indexes + nodes_by_label: ?*Ht, // key: label, value: (NodePtrArray*) + nodes_by_name: ?*Ht, // key: name, value: (NodePtrArray*) + + /// Edge storage: array of pointers to individually heap-allocated edges + edges: DynArray(*Edge), + + /// Edge dedup index: "srcID:tgtID:type" → Edge* + edge_by_key: ?*Ht, + + // Edge secondary indexes: composite keys → EdgePtrArray + edges_by_source_type: ?*Ht, // "srcID:type" → EdgePtrArray* + edges_by_target_type: ?*Ht, // "tgtID:type" → EdgePtrArray* + edges_by_type: ?*Ht, // "type" → EdgePtrArray* + + /// String intern pool for highly-repetitive fields (node label/file_path, + /// edge type). Maps string content → owned canonical copy, collapsing + /// O(nodes+edges) duplicate allocations to O(distinct). The pool owns the + /// copies; interned pointers are stable for the buffer lifetime and are NOT + /// freed by freeNodeStrings/freeEdgeStrings — only once in cbm_gbuf_free. + intern_pool: ?*Ht, + + /// Vector storage for semantic embeddings (filled by pass_semantic_edges, + /// consumed by the cbm.zova publish during dump). + dump_vectors: ?[*]c.CBMDumpVector, + dump_vector_count: c_int, + dump_vector_cap: c_int, + + /// Token vector storage for enriched RI vectors (query-time lookup). + dump_token_vecs: ?[*]c.CBMDumpTokenVec, + dump_token_vec_count: c_int, + dump_token_vec_cap: c_int, + reuse_zova_semantic_vectors: bool, + + /// Finalized lightweight topology retained after the SQLite writer closes. + /// Sidecar publication happens after the pipeline persists hashes/FTS, so + /// it must consume these arrays without re-scanning SQLite graph rows. + zova_dump_nodes: ?[*]c.CBMDumpNode, + zova_dump_node_count: c_int, + zova_dump_edges: ?[*]c.CBMDumpEdge, + zova_dump_edge_count: c_int, + zova_prepared_view: ?*c.cbm_zova_prepared_view_t, + + /// The SQLite writer and shared user-local publisher must describe + /// the same indexed generation. Keep the timestamp produced for the dump + /// so publication does not create a second, later timestamp. + indexed_at: [64]u8, + + fn dumpVectors(gb: *const Gbuf) []c.CBMDumpVector { + const items = gb.dump_vectors orelse return &.{}; + return items[0..@intCast(gb.dump_vector_count)]; + } + + fn dumpTokenVecs(gb: *const Gbuf) []c.CBMDumpTokenVec { + const items = gb.dump_token_vecs orelse return &.{}; + return items[0..@intCast(gb.dump_token_vec_count)]; + } + + /// Is the node still in the QN index (not deleted)? + fn isLive(gb: *const Gbuf, n: *const Node) bool { + return n.qualified_name != null and c.cbm_ht_get(gb.node_by_qn, n.qualified_name) != null; + } +}; + +// ── Helpers ──────────────────────────────────────────────────────── + +fn htGet(comptime T: type, ht: ?*const Ht, key: CStr) ?*T { + return @ptrCast(@alignCast(c.cbm_ht_get(ht, key))); +} + +/// s, or "" when s is NULL. +fn orEmpty(s: CStr) CStr { + return if (s != null) s else ""; +} + +fn streq(a: CStr, b: CStr) bool { + return c.strcmp(a, b) == 0; +} + +/// A zeroed malloc'd array of n items; NULL on OOM. +fn callocSlice(comptime T: type, n: usize) ?[]T { + const items: ?[*]T = interop.callocN(T, n); + return (items orelse return null)[0..n]; +} + +/// Copy s, or "{}" when s is NULL. +fn dupOrEmptyJson(s: CStr) [*c]u8 { + return c.cbm_heap_strdup(if (s != null) s else "{}"); +} + +/// Intern a repetitive string into the buffer's pool: identical content collapses +/// to a single heap copy owned by the pool. NULL maps to "{}" (matches +/// dupOrEmptyJson). The returned pointer is stable for the buffer's lifetime and +/// must never be freed or mutated by callers. Returns NULL only on OOM. +fn gbIntern(gb: *Gbuf, s: CStr) [*c]u8 { + const key: CStr = if (s != null) s else "{}"; + if (c.cbm_ht_get(gb.intern_pool, key)) |found| return @ptrCast(found); + const copy: [*c]u8 = c.strdup(key); + if (copy != null) _ = c.cbm_ht_set(gb.intern_pool, copy, copy); // key == value == owned copy + return copy; +} + +fn makeIdKey(buf: *[ID_KEY_BUF]u8, id: i64) void { + _ = interop.bprint(buf, "{d}", .{id}); +} + +/// FNV-1a 64-bit over a byte slice — for re-keying oversized local_names. +fn fnv1a64(s: []const u8) u64 { + var h: u64 = 14695981039346656037; + for (s) |byte| { + h ^= byte; + h *%= 1099511628211; + } + return h; +} + +/// IMPORTS edges carry exactly one imported symbol's local_name (#768): two +/// named imports from the same specifier resolve to the same (source, +/// target) pair but are distinct symbols. Key on local_name too so the +/// second import doesn't dedup-collide with and overwrite the first — +/// every pass that walks IMPORTS edges (the call resolver in pass_parallel.c, +/// pass_lsp_cross.c) expects one local_name per edge, so +/// losing an edge here silently breaks cross-file call resolution for +/// whichever symbol got dropped, not just "who imports X" queries. Other +/// edge types keep the plain (source,target,type) key: collapsing repeat +/// edges of the same type between the same two nodes (e.g. multiple call +/// sites) into one is the existing, intended dedup behavior there. +/// +/// A local_name too long for the key buffer is re-keyed with an FNV-1a hash +/// of the FULL name instead of being truncated — a truncated key would +/// collide two long names sharing a prefix and silently drop an edge again. +/// The hash key is prefixed with byte 0x01, which cannot appear in the raw +/// JSON slice (control characters must be \u-escaped in JSON), so hash keys +/// can never collide with verbatim keys. +fn makeEdgeKey(buf: *[EDGE_KEY_BUF]u8, src: i64, tgt: i64, type_c: CStr, properties_json: CStr) void { + const edge_type = std.mem.span(type_c); + if (properties_json != null and std.mem.eql(u8, edge_type, "IMPORTS")) { + const local_name_key = "\"local_name\":\""; + const props = std.mem.span(properties_json); + if (std.mem.indexOf(u8, props, local_name_key)) |at| { + const rest = props[at + local_name_key.len ..]; + const local_name = rest[0 .. std.mem.indexOfScalar(u8, rest, '"') orelse rest.len]; + const n = interop.bprint(buf, "{d}:{d}:{s}:{s}", .{ src, tgt, edge_type, local_name }); + if (n >= buf.len) { + _ = interop.bprint(buf, "{d}:{d}:{s}:\x01{x:0>16}", .{ src, tgt, edge_type, fnv1a64(local_name) }); + } + return; + } + } + _ = interop.bprint(buf, "{d}:{d}:{s}", .{ src, tgt, edge_type }); +} + +fn makeSrcTypeKey(buf: *[EDGE_KEY_BUF]u8, src: i64, type_c: CStr) void { + _ = interop.bprint(buf, "{d}:{s}", .{ src, interop.sp(type_c) }); +} + +/// Get or create a pointer array (NodePtrArray or EdgePtrArray) in a hash +/// table. NULL on OOM. +fn getOrCreateArray(comptime A: type, ht: ?*Ht, key: CStr) ?*A { + if (htGet(A, ht, key)) |arr| return arr; + const arr: *A = @ptrCast(@alignCast(c.calloc(1, @sizeOf(A)) orelse return null)); + _ = c.cbm_ht_set(ht, c.strdup(key), arr); + return arr; +} + +/// Free a NodePtrArray (callback for hash table iteration). +fn freeNodeArray(key: [*c]const u8, value: ?*anyopaque, ud: ?*anyopaque) callconv(.c) void { + _ = ud; + if (value) |v| { + const arr: *NodePtrArray = @ptrCast(@alignCast(v)); + arr.free(); + c.free(arr); + } + c.free(@constCast(key)); +} + +/// Free an EdgePtrArray (callback). +fn freeEdgeArray(key: [*c]const u8, value: ?*anyopaque, ud: ?*anyopaque) callconv(.c) void { + _ = ud; + if (value) |v| { + const arr: *EdgePtrArray = @ptrCast(@alignCast(v)); + arr.free(); + c.free(arr); + } + c.free(@constCast(key)); +} + +/// Free keys only (for edge_by_key, deleted_set). +fn freeKeyOnly(key: [*c]const u8, value: ?*anyopaque, ud: ?*anyopaque) callconv(.c) void { + _ = value; + _ = ud; + c.free(@constCast(key)); +} + +/// Free a single node's owned strings. label and file_path are interned +/// (pool-owned) — NOT freed here; the pool frees them once in cbm_gbuf_free. +fn freeNodeStrings(n: *Node) void { + c.free(n.name); + c.free(n.qualified_name); + c.free(n.properties_json); +} + +/// Free a single edge's owned strings. type is interned (pool-owned) — NOT +/// freed here; the pool frees it once in cbm_gbuf_free. +fn freeEdgeStrings(e: *Edge) void { + c.free(e.properties_json); +} + +/// Allocate the next buffer-local or shared-atomic ID. +fn allocNextId(gb: *Gbuf) i64 { + if (gb.shared_ids) |ids| return ids.fetchAdd(1, .monotonic); + const id = gb.next_id; + gb.next_id += 1; + return id; +} + +/// Swap-remove an edge from a pointer array by ID. +fn removeEdgeFromPtrArray(arr_opt: ?*EdgePtrArray, edge_id: i64) void { + const arr = arr_opt orelse return; + const items = arr.slice(); + for (items, 0..) |item, j| { + if (item.id == edge_id) { + arr.count -= 1; + items[j] = items[@intCast(arr.count)]; + return; + } + } +} + +/// Swap-remove a node from a NodePtrArray by ID. +fn removeNodeFromPtrArray(arr_opt: ?*NodePtrArray, node_id: i64) void { + const arr = arr_opt orelse return; + const items = arr.slice(); + for (items, 0..) |item, j| { + if (item.id == node_id) { + arr.count -= 1; + items[j] = items[@intCast(arr.count)]; + return; + } + } +} + +/// Remove an edge from all indexes (dedup + source_type + target_type + type). +fn unindexEdge(gb: *Gbuf, e: *const Edge) void { + var key: [EDGE_KEY_BUF]u8 = undefined; + + makeEdgeKey(&key, e.source_id, e.target_id, e.type, e.properties_json); + const ekey = c.cbm_ht_get_key(gb.edge_by_key, &key); + _ = c.cbm_ht_delete(gb.edge_by_key, &key); + c.free(@constCast(ekey)); + + makeSrcTypeKey(&key, e.source_id, e.type); + removeEdgeFromPtrArray(htGet(EdgePtrArray, gb.edges_by_source_type, &key), e.id); + + makeSrcTypeKey(&key, e.target_id, e.type); + removeEdgeFromPtrArray(htGet(EdgePtrArray, gb.edges_by_target_type, &key), e.id); + + removeEdgeFromPtrArray(htGet(EdgePtrArray, gb.edges_by_type, e.type), e.id); +} + +/// Cascade-delete all edges touching nodes in deleted_set. +fn cascadeDeleteEdges(gb: *Gbuf, deleted_set: ?*Ht) void { + const edges = gb.edges.slice(); + var write_idx: usize = 0; + for (edges) |e| { + var src_id: [ID_KEY_BUF]u8 = undefined; + var tgt_id: [ID_KEY_BUF]u8 = undefined; + makeIdKey(&src_id, e.source_id); + makeIdKey(&tgt_id, e.target_id); + + if (c.cbm_ht_get(deleted_set, &src_id) != null or c.cbm_ht_get(deleted_set, &tgt_id) != null) { + unindexEdge(gb, e); + freeEdgeStrings(e); + c.free(e); + } else { + edges[write_idx] = e; + write_idx += 1; + } + } + gb.edges.count = @intCast(write_idx); +} + +/// Register a node in primary (QN, ID) and secondary (label, name) indexes. +fn registerNodeInIndexes(gb: *Gbuf, node: *Node) void { + _ = c.cbm_ht_set(gb.node_by_qn, node.qualified_name, node); + + if (node.id >= gb.by_id_cap) { + var nc: i64 = if (gb.by_id_cap > 0) gb.by_id_cap else 1024; + while (nc <= node.id) nc *= 2; + if (c.realloc(@ptrCast(gb.by_id), @as(usize, @intCast(nc)) * @sizeOf(?*Node))) |raw| { + const grown: [*]?*Node = @ptrCast(@alignCast(raw)); + @memset(grown[@intCast(gb.by_id_cap)..@intCast(nc)], null); + gb.by_id = grown; + gb.by_id_cap = nc; + } + } + if (node.id >= 0 and node.id < gb.by_id_cap) { + gb.by_id.?[@intCast(node.id)] = node; + } + + if (getOrCreateArray(NodePtrArray, gb.nodes_by_label, orEmpty(node.label))) |by_label| by_label.push(node); + if (getOrCreateArray(NodePtrArray, gb.nodes_by_name, orEmpty(node.name))) |by_name| by_name.push(node); +} + +/// Index an edge by one key into a hash table bucket. +fn indexEdgeByKey(ht: ?*Ht, key: CStr, edge: *Edge) void { + if (getOrCreateArray(EdgePtrArray, ht, key)) |arr| arr.push(edge); +} + +/// Register an edge in secondary indexes (source_type, target_type, type). +fn registerEdgeInIndexes(gb: *Gbuf, edge: *Edge) void { + var key: [EDGE_KEY_BUF]u8 = undefined; + + makeSrcTypeKey(&key, edge.source_id, edge.type); + indexEdgeByKey(gb.edges_by_source_type, &key, edge); + + makeSrcTypeKey(&key, edge.target_id, edge.type); + indexEdgeByKey(gb.edges_by_target_type, &key, edge); + + indexEdgeByKey(gb.edges_by_type, edge.type, edge); +} + +/// cbm_ht_foreach(ht, free_fn) + cbm_ht_free(ht). +fn freeIndex(ht: ?*Ht, free_fn: c.cbm_ht_iter_fn) void { + c.cbm_ht_foreach(ht, free_fn, null); + c.cbm_ht_free(ht); +} + +/// Rebuild edge secondary indexes from scratch (after bulk deletion). +fn rebuildEdgeSecondaryIndexes(gb: *Gbuf) void { + freeIndex(gb.edges_by_source_type, freeEdgeArray); + freeIndex(gb.edges_by_target_type, freeEdgeArray); + freeIndex(gb.edges_by_type, freeEdgeArray); + + gb.edges_by_source_type = c.cbm_ht_create(256); + gb.edges_by_target_type = c.cbm_ht_create(256); + gb.edges_by_type = c.cbm_ht_create(32); + + for (gb.edges.slice()) |e| registerEdgeInIndexes(gb, e); +} + +/// Release all lookup hash tables (used by dump after building arrays). +fn releaseGbufIndexes(gb: *Gbuf) void { + c.cbm_ht_free(gb.node_by_qn); + gb.node_by_qn = null; + c.free(@ptrCast(gb.by_id)); + gb.by_id = null; + gb.by_id_cap = 0; + freeIndex(gb.nodes_by_label, freeNodeArray); + gb.nodes_by_label = null; + freeIndex(gb.nodes_by_name, freeNodeArray); + gb.nodes_by_name = null; + freeIndex(gb.edge_by_key, freeKeyOnly); + gb.edge_by_key = null; + freeIndex(gb.edges_by_source_type, freeEdgeArray); + gb.edges_by_source_type = null; + freeIndex(gb.edges_by_target_type, freeEdgeArray); + gb.edges_by_target_type = null; + freeIndex(gb.edges_by_type, freeEdgeArray); + gb.edges_by_type = null; +} + +// ── Lifecycle ────────────────────────────────────────────────────── + +pub export fn cbm_gbuf_new(project: ?Str, root_path: ?Str) ?*Gbuf { + const gb: *Gbuf = @ptrCast(@alignCast(c.calloc(1, @sizeOf(Gbuf)) orelse return null)); + + gb.project = c.strdup(project orelse ""); + gb.root_path = c.strdup(root_path orelse ""); + gb.next_id = 1; + gb.shared_ids = null; + + gb.node_by_qn = c.cbm_ht_create(256); + gb.by_id = null; + gb.by_id_cap = 0; + gb.nodes_by_label = c.cbm_ht_create(32); + gb.nodes_by_name = c.cbm_ht_create(256); + + gb.edge_by_key = c.cbm_ht_create(512); + gb.edges_by_source_type = c.cbm_ht_create(256); + gb.edges_by_target_type = c.cbm_ht_create(256); + gb.edges_by_type = c.cbm_ht_create(32); + + gb.intern_pool = c.cbm_ht_create(1024); + + return gb; +} + +// Not `pub`: translate-c cannot express the header's `_Atomic int64_t *` +// parameter, so there is no translated declaration to check this against. +export fn cbm_gbuf_new_shared_ids(project: ?Str, root_path: ?Str, id_source: ?*std.atomic.Value(i64)) ?*Gbuf { + const gb = cbm_gbuf_new(project, root_path) orelse return null; + if (id_source) |ids| gb.shared_ids = ids; + return gb; +} + +pub export fn cbm_gbuf_reserve(gb_c: ?*Gbuf, node_count: c_int, edge_count: c_int) c_int { + const gb = gb_c orelse return NOT_FOUND; + if (node_count < 0 or edge_count < 0) return NOT_FOUND; + if (!gb.nodes.reserve(node_count)) return NOT_FOUND; + if (!gb.edges.reserve(edge_count)) return NOT_FOUND; + return 0; +} + +pub export fn cbm_gbuf_free(gb_c: ?*Gbuf) void { + const gb = gb_c orelse return; + + c.free(@ptrCast(gb.zova_dump_nodes)); + freeRetainedDumpEdges(gb.zova_dump_edges, gb.zova_dump_edge_count); + c.cbm_zova_prepared_view_free(gb.zova_prepared_view); + + // Free each individually-allocated node + for (gb.nodes.slice()) |n| { + freeNodeStrings(n); + c.free(n); + } + gb.nodes.free(); + + // Free each individually-allocated edge + for (gb.edges.slice()) |e| { + freeEdgeStrings(e); + c.free(e); + } + gb.edges.free(); + + // Free hash tables — may be NULL if already released by the dump + if (gb.node_by_qn != null) c.cbm_ht_free(gb.node_by_qn); + c.free(@ptrCast(gb.by_id)); + if (gb.nodes_by_label != null) freeIndex(gb.nodes_by_label, freeNodeArray); + if (gb.nodes_by_name != null) freeIndex(gb.nodes_by_name, freeNodeArray); + if (gb.edge_by_key != null) freeIndex(gb.edge_by_key, freeKeyOnly); + if (gb.edges_by_source_type != null) freeIndex(gb.edges_by_source_type, freeEdgeArray); + if (gb.edges_by_target_type != null) freeIndex(gb.edges_by_target_type, freeEdgeArray); + if (gb.edges_by_type != null) freeIndex(gb.edges_by_type, freeEdgeArray); + + // Free vector storage + for (gb.dumpVectors()) |v| c.free(@constCast(v.vector)); + c.free(@ptrCast(gb.dump_vectors)); + + // Free token vector storage + for (gb.dumpTokenVecs()) |tv| { + c.free(@constCast(tv.token)); + c.free(@constCast(tv.vector)); + } + c.free(@ptrCast(gb.dump_token_vecs)); + + // Free interned strings (node label/file_path, edge type) — pool owns one + // copy each (key == value), freed exactly once via freeKeyOnly. Done after + // nodes/edges since they borrowed these pointers. + if (gb.intern_pool != null) freeIndex(gb.intern_pool, freeKeyOnly); + + c.free(gb.project); + c.free(gb.root_path); + c.free(gb); +} + +// ── Vector storage ───────────────────────────────────────────────── + +pub export fn cbm_gbuf_clear_semantic_vectors(gb_c: ?*Gbuf) void { + const gb = gb_c orelse return; + for (gb.dumpVectors()) |v| c.free(@constCast(v.vector)); + gb.dump_vector_count = 0; + for (gb.dumpTokenVecs()) |tv| { + c.free(@constCast(tv.token)); + c.free(@constCast(tv.vector)); + } + gb.dump_token_vec_count = 0; +} + +pub export fn cbm_gbuf_reuse_zova_semantic_vectors(gb_c: ?*Gbuf) c_int { + const gb = gb_c orelse return NOT_FOUND; + if (gb.dump_vector_count != 0 or gb.dump_token_vec_count != 0) return NOT_FOUND; + gb.reuse_zova_semantic_vectors = true; + return 0; +} + +/// A malloc'd copy of the first `len` bytes of `vector`. +fn dupVector(vector: [*]const u8, len: usize) ?[*]u8 { + const copy: [*]u8 = @ptrCast(c.malloc(len) orelse return null); + @memcpy(copy[0..len], vector[0..len]); + return copy; +} + +pub export fn cbm_gbuf_store_vector(gb_c: ?*Gbuf, node_id: i64, vector_c: ?[*]const u8, vector_len: c_int) c_int { + const gb = gb_c orelse return GB_ERR; + const vector = vector_c orelse return GB_ERR; + if (vector_len <= 0) return GB_ERR; + const VEC_INIT_CAP = 1024; + const VEC_GROW = 2; + if (gb.dump_vector_count >= gb.dump_vector_cap) { + const new_cap: c_int = if (gb.dump_vector_cap < VEC_INIT_CAP) VEC_INIT_CAP else gb.dump_vector_cap * VEC_GROW; + const grown = c.realloc(@ptrCast(gb.dump_vectors), @as(usize, @intCast(new_cap)) * @sizeOf(c.CBMDumpVector)) orelse return GB_ERR; + gb.dump_vectors = @ptrCast(@alignCast(grown)); + gb.dump_vector_cap = new_cap; + } + // Copy vector data + const vec_copy = dupVector(vector, @intCast(vector_len)) orelse return GB_ERR; + + gb.dump_vectors.?[@intCast(gb.dump_vector_count)] = .{ + .node_id = node_id, + .project = gb.project, // borrowed — valid until cbm_gbuf_free + .vector = vec_copy, + .vector_len = vector_len, + }; + gb.dump_vector_count += 1; + return 0; +} + +pub export fn cbm_gbuf_store_token_vector(gb_c: ?*Gbuf, token: ?Str, vector_c: ?[*]const u8, vector_len: c_int, idf: f32) c_int { + const gb = gb_c orelse return GB_ERR; + const vector = vector_c orelse return GB_ERR; + if (token == null or vector_len <= 0) return GB_ERR; + const TV_INIT_CAP = 256; + const TV_GROW = 2; + if (gb.dump_token_vec_count >= gb.dump_token_vec_cap) { + const new_cap: c_int = if (gb.dump_token_vec_cap < TV_INIT_CAP) TV_INIT_CAP else gb.dump_token_vec_cap * TV_GROW; + const grown = c.realloc(@ptrCast(gb.dump_token_vecs), @as(usize, @intCast(new_cap)) * @sizeOf(c.CBMDumpTokenVec)) orelse return GB_ERR; + gb.dump_token_vecs = @ptrCast(@alignCast(grown)); + gb.dump_token_vec_cap = new_cap; + } + const vec_copy = dupVector(vector, @intCast(vector_len)) orelse return GB_ERR; + + const idx = gb.dump_token_vec_count; + gb.dump_token_vecs.?[@intCast(idx)] = .{ + .id = idx + 1, // 1-based sequential ID + .project = gb.project, + .token = c.strdup(token), + .vector = vec_copy, + .vector_len = vector_len, + .idf = idf, + }; + gb.dump_token_vec_count += 1; + return 0; +} + +// ── ID accessors ─────────────────────────────────────────────────── + +pub export fn cbm_gbuf_next_id(gb_c: ?*const Gbuf) i64 { + const gb = gb_c orelse return 1; + if (gb.shared_ids) |ids| return ids.load(.seq_cst); + return gb.next_id; +} + +pub export fn cbm_gbuf_set_next_id(gb_c: ?*Gbuf, next_id: i64) void { + const gb = gb_c orelse return; + gb.next_id = next_id; +} + +pub export fn cbm_gbuf_raise_next_id(gb_c: ?*Gbuf, next_id: i64) void { + const gb = gb_c orelse return; + if (next_id > gb.next_id) gb.next_id = next_id; +} + +/// Sorts ids, counts each id an earlier entry already carries, frees ids. +fn countDuplicateIds(ids: []i64) c_int { + std.mem.sortUnstable(i64, ids, {}, std.sort.asc(i64)); + var dups: c_int = 0; + for (ids[1..], ids[0 .. ids.len - 1]) |id, prev| dups += @intFromBool(id == prev); + c.free(ids.ptr); + return dups; +} + +pub export fn cbm_gbuf_duplicate_edge_ids(gb_c: ?*const Gbuf) c_int { + const gb = gb_c orelse return 0; + const edges = gb.edges.slice(); + if (edges.len < 2) return 0; + const ids = callocSlice(i64, edges.len) orelse return NOT_FOUND; + for (edges, ids) |e, *id| id.* = e.id; + return countDuplicateIds(ids); +} + +pub export fn cbm_gbuf_duplicate_node_ids(gb_c: ?*const Gbuf) c_int { + const gb = gb_c orelse return 0; + const nodes = gb.nodes.slice(); + if (nodes.len < 2) return 0; + const ids = callocSlice(i64, nodes.len) orelse return NOT_FOUND; + for (nodes, ids) |n, *id| id.* = n.id; + return countDuplicateIds(ids); +} + +// ── Node operations ──────────────────────────────────────────────── + +/// First key of the canonical same-QN collision order: how strong a claim the +/// entity has on a qualified name. A macro is the weakest: when a "Macro" and +/// a definition share a QN, the definition, which carries the body, the +/// signature and every edge, owns it whichever comes first. +/// +/// C-preprocessor macros no longer get here: their QN carries its own +/// namespace (`...#macro`, CBM_MACRO_QN_SUFFIX) and cannot equal a definition's. +/// The case left is Chialisp, whose `defmacro` / `defmac` defs are labelled +/// "Macro" under a plain QN: a macro and a `defun` of one name in one file, or +/// in a same-stem `.clsp` / `.clib` pair (one module QN). +fn gbQnClaimRank(label: CStr) c_int { + return if (label != null and streq(label, "Macro")) 0 else 1; +} + +/// Is a per-file "Module" def arriving on a structural directory node +/// ("Project" root or "Folder") that shares its QN? +fn moduleOnContainer(existing_label: CStr, label: CStr) bool { + return existing_label != null and label != null and streq(label, "Module") and + (streq(existing_label, "Project") or streq(existing_label, "Folder")); +} + +/// The canonical same-QN collision order: > 0 when the existing node wins +/// over the arriving one, 0 on a full tie. See cbm_gbuf_upsert_node. +fn collisionOrder(existing: *const Node, label: CStr, name: CStr, file_path: CStr, start_line: c_int) c_int { + var order = gbQnClaimRank(existing.label) - gbQnClaimRank(label); + if (order == 0) order = c.strcmp(orEmpty(file_path), orEmpty(existing.file_path)); + if (order == 0) order = existing.start_line -% start_line; + if (order == 0) order = c.strcmp(orEmpty(existing.name), orEmpty(name)); + if (order == 0) order = c.strcmp(orEmpty(existing.label), orEmpty(label)); + return order; +} + +pub export fn cbm_gbuf_upsert_node(gb_c: ?*Gbuf, label: ?Str, name: ?Str, qualified_name: ?Str, file_path: ?Str, start_line: c_int, end_line: c_int, properties_json: ?Str) i64 { + const gb = gb_c orelse return 0; + if (qualified_name == null) return 0; + + // Check if node already exists + if (htGet(Node, gb.node_by_qn, qualified_name)) |existing| { + // Don't let a per-file "Module" def touch a structural directory node + // ("Project" root or "Folder"). In a directory-based-module language + // (Go/Java) a file's module_qn equals its directory QN: a root file → + // the project name (== the "Project" node's QN); a file in pkg/ → + // proj.pkg (== the "pkg/" Folder node's QN). Its always-emitted Module + // def collides here; the directory node is the package/module container + // and must keep its structural label AND its own name/file_path/range. + // Updating those in place set the shared node's file_path to whichever + // same-package file happened to be processed LAST (worker-order + // dependent) — the nondeterministic file attribution behind #787 — and + // left the Folder node exposed to delete-nodes-by-file on incremental + // reindex of that file. Skip the update entirely. (Both direct + // upserts and the parallel local-gbuf merge route through this function.) + if (moduleOnContainer(existing.label, label)) return existing.id; + + // Same-QN arrival: distinct source entities can share a QN (C: a + // struct, a function and a macro with one name), and the same entity + // can be re-upserted with fresh content. The old code let the LAST + // arrival overwrite — under parallel extraction the merge order + // varies run to run, so WHICH entity survived flickered (xfs: + // Function-node count 4998 vs 5015 across two runs) and every + // order-sensitive consumer downstream inherited it. Pick the + // survivor by a canonical CONTENT rule instead, a pure function of + // the two candidates: smallest file_path, then LARGEST start_line, + // then largest name/label (a mixed-direction composite is still a + // total order, so the pick is commutative and scheduling-free). + // Line-descending within a file keeps the classic upsert contract — + // a later definition in the same file (macro redefinition, refresh + // of the same entity with new content) replaces the earlier one, + // deterministically, because intra-file arrival order is fixed. A + // full tie is the same entity re-upserted → refresh in place. + // Ahead of all of these, a definition beats a macro + // (gbQnClaimRank). Kind-disambiguated QNs (the real cure) remain + // a follow-up. + if (collisionOrder(existing, label, name, file_path, start_line) > 0) { + return existing.id; // existing entity is the canonical winner + } + + // Update in-place. name/properties are strdup'd BEFORE freeing old ones + // (callers may pass existing.name as an argument). label/file_path are + // interned: gbIntern returns a stable pool pointer (idempotent even when + // label == existing.label), so the old value is replaced, never freed. + // When the surviving label/name changes, keep the secondary indexes + // consistent (the old code left the node listed under its ORIGINAL + // label/name — cbm_gbuf_find_by_label/name then missed or mis-listed it, + // which is how the flickering Function set reached the semantic pass). + const new_name = dupOrEmptyJson(name); + const new_props: [*c]u8 = if (properties_json != null) dupOrEmptyJson(properties_json) else null; + const new_label = gbIntern(gb, label); + const label_changed = existing.label == null or new_label == null or !streq(existing.label, new_label); + const name_changed = existing.name == null or new_name == null or !streq(existing.name, new_name); + if (label_changed) { + removeNodeFromPtrArray(htGet(NodePtrArray, gb.nodes_by_label, orEmpty(existing.label)), existing.id); + } + if (name_changed) { + removeNodeFromPtrArray(htGet(NodePtrArray, gb.nodes_by_name, orEmpty(existing.name)), existing.id); + } + existing.label = new_label; + c.free(existing.name); + existing.name = new_name; + existing.file_path = gbIntern(gb, file_path); + existing.start_line = start_line; + existing.end_line = end_line; + if (new_props != null) { + c.free(existing.properties_json); + existing.properties_json = new_props; + } + if (label_changed) { + if (getOrCreateArray(NodePtrArray, gb.nodes_by_label, orEmpty(existing.label))) |by_label| by_label.push(existing); + } + if (name_changed) { + if (getOrCreateArray(NodePtrArray, gb.nodes_by_name, orEmpty(existing.name))) |by_name| by_name.push(existing); + } + return existing.id; + } + + // Heap-allocate a new node (pointer stays stable across array growth) + const node: *Node = @ptrCast(@alignCast(c.calloc(1, @sizeOf(Node)) orelse return 0)); + + const id = allocNextId(gb); + node.id = id; + node.label = gbIntern(gb, label); + node.name = dupOrEmptyJson(name); + node.qualified_name = dupOrEmptyJson(qualified_name); + node.file_path = gbIntern(gb, file_path); + node.start_line = start_line; + node.end_line = end_line; + node.properties_json = dupOrEmptyJson(properties_json); + + // Store pointer in array and register in all indexes + gb.nodes.push(node); + registerNodeInIndexes(gb, node); + + return id; +} + +pub export fn cbm_gbuf_find_by_qn(gb_c: ?*const Gbuf, qn: ?Str) ?*const Node { + const gb = gb_c orelse return null; + if (qn == null) return null; + return htGet(Node, gb.node_by_qn, qn); +} + +pub export fn cbm_gbuf_set_node_props(gb_c: ?*Gbuf, qualified_name: ?Str, properties_json: ?Str) bool { + const gb = gb_c orelse return false; + if (qualified_name == null or properties_json == null) return false; + const node = htGet(Node, gb.node_by_qn, qualified_name) orelse return false; + const new_props = dupOrEmptyJson(properties_json); + if (new_props == null) return false; + c.free(node.properties_json); + node.properties_json = new_props; + return true; +} + +pub export fn cbm_gbuf_find_by_id(gb_c: ?*const Gbuf, id: i64) ?*const Node { + const gb = gb_c orelse return null; + const by_id = gb.by_id orelse return null; + if (id < 0 or id >= gb.by_id_cap) return null; + return by_id[@intCast(id)]; +} + +/// Hand a pointer array out through the find_* out parameters: NULL and 0 +/// for a missing or empty array. +fn returnArray(comptime A: type, arr_opt: ?*const A, out: anytype, count: *c_int) void { + if (arr_opt) |arr| { + if (arr.count > 0) { + out.* = arr.items; + count.* = arr.count; + return; + } + } + out.* = null; + count.* = 0; +} + +pub export fn cbm_gbuf_find_by_label(gb_c: ?*const Gbuf, label: ?Str, out_c: ?*?[*]*const Node, count_c: ?*c_int) c_int { + const gb = gb_c orelse return NOT_FOUND; + const out = out_c orelse return NOT_FOUND; + const count = count_c orelse return NOT_FOUND; + returnArray(NodePtrArray, htGet(NodePtrArray, gb.nodes_by_label, label orelse ""), out, count); + return 0; +} + +pub export fn cbm_gbuf_find_by_name(gb_c: ?*const Gbuf, name: ?Str, out_c: ?*?[*]*const Node, count_c: ?*c_int) c_int { + const gb = gb_c orelse return NOT_FOUND; + const out = out_c orelse return NOT_FOUND; + const count = count_c orelse return NOT_FOUND; + returnArray(NodePtrArray, htGet(NodePtrArray, gb.nodes_by_name, name orelse ""), out, count); + return 0; +} + +pub export fn cbm_gbuf_node_count(gb_c: ?*const Gbuf) c_int { + // Use QN hash table count since it's authoritative (handles deletes) + const gb = gb_c orelse return 0; + return @intCast(c.cbm_ht_count(gb.node_by_qn)); +} + +/// Mark a node id deleted in `deleted_set`. +fn markDeleted(deleted_set: ?*Ht, id: i64) void { + var id_buf: [ID_KEY_BUF]u8 = undefined; + makeIdKey(&id_buf, id); + _ = c.cbm_ht_set(deleted_set, c.strdup(&id_buf), @ptrFromInt(1)); +} + +pub export fn cbm_gbuf_delete_by_label(gb_c: ?*Gbuf, label: ?Str) c_int { + const gb = gb_c orelse return NOT_FOUND; + if (label == null) return NOT_FOUND; + + const arr = htGet(NodePtrArray, gb.nodes_by_label, label) orelse return 0; + if (arr.count == 0) return 0; + + // Build hash set of deleted node IDs for O(1) lookup + const deleted_set = c.cbm_ht_create(@intCast(arr.count)); + for (arr.slice()) |n| { + markDeleted(deleted_set, n.id); + + // Remove from primary indexes + _ = c.cbm_ht_delete(gb.node_by_qn, n.qualified_name); + if (n.id >= 0 and n.id < gb.by_id_cap) gb.by_id.?[@intCast(n.id)] = null; + } + + // Clear the label array + arr.count = 0; + + // Cascade-delete edges referencing deleted nodes + cascadeDeleteEdges(gb, deleted_set); + + freeIndex(deleted_set, freeKeyOnly); + return 0; +} + +pub export fn cbm_gbuf_delete_by_file(gb_c: ?*Gbuf, file_path: ?Str) c_int { + const gb = gb_c orelse return NOT_FOUND; + if (file_path == null) return NOT_FOUND; + + // Collect IDs of nodes in this file + const deleted_set = c.cbm_ht_create(64); + var deleted_count: c_int = 0; + var scanned: c_int = 0; + + for (gb.nodes.slice()) |n| { + scanned += 1; + if (n.file_path == null or !streq(n.file_path, file_path)) continue; + if (!gb.isLive(n)) continue; + + markDeleted(deleted_set, n.id); + + // Remove from secondary indexes + removeNodeFromPtrArray(htGet(NodePtrArray, gb.nodes_by_label, n.label), n.id); + removeNodeFromPtrArray(htGet(NodePtrArray, gb.nodes_by_name, n.name), n.id); + + // Remove from primary indexes + _ = c.cbm_ht_delete(gb.node_by_qn, n.qualified_name); + if (n.id >= 0 and n.id < gb.by_id_cap) gb.by_id.?[@intCast(n.id)] = null; + + // NULL out QN so dump's liveness check (cbm_ht_get by QN) fails + // even if a new node with the same QN is inserted later via merge. + c.free(n.qualified_name); + n.qualified_name = null; + deleted_count += 1; + } + + if (deleted_count == 0) { + c.cbm_ht_free(deleted_set); + return 0; + } + + // Cascade-delete edges referencing deleted nodes + cascadeDeleteEdges(gb, deleted_set); + + freeIndex(deleted_set, freeKeyOnly); + { + var s_buf: [16]u8 = undefined; + var d_buf: [16]u8 = undefined; + interop.logInfo("gbuf.delete_by_file", .{ "file", file_path, "scanned", fmtInt(&s_buf, scanned), "deleted", fmtInt(&d_buf, deleted_count) }); + } + return deleted_count; +} + +/// snprintf(buf, sizeof(buf), "%d", v) for a log value. +fn fmtInt(buf: *[16]u8, v: c_int) Str { + _ = interop.bprint(buf, "{d}", .{v}); + return @ptrCast(buf); +} + +fn gbufFolderMatchesAffectedPath(folder_path: CStr, affected_path: CStr) bool { + if (folder_path == null or folder_path[0] == 0 or affected_path == null) return false; + const folder = std.mem.span(folder_path); + const affected = std.mem.span(affected_path); + return std.mem.startsWith(u8, affected, folder) and + (affected.len == folder.len or affected[folder.len] == '/'); +} + +fn gbufDeleteEmptyFoldersImpl(gb_c: ?*Gbuf, affected_paths: []const CStr, scoped: bool) c_int { + const gb = gb_c orelse return NOT_FOUND; + var removed: c_int = 0; + var changed = true; + while (changed) { + changed = false; + var folders: ?[*]*const Node = null; + var count: c_int = 0; + if (cbm_gbuf_find_by_label(gb, "Folder", &folders, &count) != 0) break; + const list: []const *const Node = if (folders) |f| f[0..@intCast(count)] else &.{}; + for (list) |folder| { + if (folder.file_path == null or folder.qualified_name == null) continue; + if (scoped) { + const affected = for (affected_paths) |path| { + if (gbufFolderMatchesAffectedPath(folder.file_path, path)) break true; + } else false; + if (!affected) continue; + } + var edges: ?[*]*const Edge = null; + var edge_count: c_int = 0; + _ = cbm_gbuf_find_edges_by_source_type(gb, folder.id, "CONTAINS_FILE", &edges, &edge_count); + if (edge_count > 0) continue; + _ = cbm_gbuf_find_edges_by_source_type(gb, folder.id, "CONTAINS_FOLDER", &edges, &edge_count); + if (edge_count > 0) continue; + _ = cbm_gbuf_delete_by_file(gb, folder.file_path); + removed += 1; + changed = true; + break; + } + } + return removed; +} + +pub export fn cbm_gbuf_delete_empty_folders(gb: ?*Gbuf) c_int { + return gbufDeleteEmptyFoldersImpl(gb, &.{}, false); +} + +pub export fn cbm_gbuf_delete_empty_folders_under(gb: ?*Gbuf, affected_paths_c: ?[*]const CStr, affected_count: c_int) c_int { + const affected_paths = affected_paths_c orelse return 0; + if (affected_count <= 0) return 0; + return gbufDeleteEmptyFoldersImpl(gb, affected_paths[0..@intCast(affected_count)], true); +} + +pub export fn cbm_gbuf_foreach_node(gb_c: ?*const Gbuf, fn_c: c.cbm_gbuf_node_visitor_fn, userdata: ?*anyopaque) void { + const gb = gb_c orelse return; + const visit = fn_c orelse return; + for (gb.nodes.slice()) |n| { + if (gb.isLive(n)) visit(n, userdata); + } +} + +pub export fn cbm_gbuf_foreach_edge(gb_c: ?*const Gbuf, fn_c: c.cbm_gbuf_edge_visitor_fn, userdata: ?*anyopaque) void { + const gb = gb_c orelse return; + const visit = fn_c orelse return; + for (gb.edges.slice()) |e| visit(e, userdata); +} + +// ── Edge operations ──────────────────────────────────────────────── + +/// "confidence": from an edge property blob; -1 when absent or +/// unreadable, so a blob that states a confidence outranks one that does not. +/// strtod answers 0.0 for text it cannot read ("confidence":null), and 0.0 is a +/// real confidence, so an end pointer that never moved means absent. +fn edgePropertiesConfidence(props_json: CStr) f64 { + const conf_key = "\"confidence\":"; + if (props_json == null) return -1.0; + const p: [*c]const u8 = c.strstr(props_json, conf_key); + if (p == null) return -1.0; + const value: [*c]const u8 = p + conf_key.len; + var end: [*c]u8 = null; + const conf = c.strtod(value, &end); + return if (end == value) -1.0 else conf; +} + +/// A total order over the two candidates, so the result does not depend on +/// arrival order: higher confidence wins (an LSP-resolved call outranks a +/// textual match, upstream d08fccd9), then the smaller blob. +fn edgePropertiesShouldReplace(existing: CStr, candidate: CStr) bool { + if (candidate == null or streq(candidate, "{}")) return false; + if (existing == null or streq(existing, "{}")) return true; + const cand_conf = edgePropertiesConfidence(candidate); + const cur_conf = edgePropertiesConfidence(existing); + if (cand_conf != cur_conf) return cand_conf > cur_conf; + return c.strcmp(candidate, existing) < 0; +} + +pub export fn cbm_gbuf_insert_edge(gb_c: ?*Gbuf, source_id: i64, target_id: i64, type_c: ?Str, properties_json: ?Str) i64 { + const gb = gb_c orelse return 0; + if (type_c == null) return 0; + + // Check for dedup + var key: [EDGE_KEY_BUF]u8 = undefined; + makeEdgeKey(&key, source_id, target_id, type_c, properties_json); + + if (htGet(Edge, gb.edge_by_key, &key)) |existing| { + // Pick one canonical value so parallel worker merge order cannot + // change the retained properties for a deduplicated edge. + if (edgePropertiesShouldReplace(existing.properties_json, properties_json)) { + const replacement = dupOrEmptyJson(properties_json); + if (replacement != null) { + c.free(existing.properties_json); + existing.properties_json = replacement; + } + } + return existing.id; + } + + // Heap-allocate a new edge (pointer stays stable) + const edge: *Edge = @ptrCast(@alignCast(c.calloc(1, @sizeOf(Edge)) orelse return 0)); + + const id = allocNextId(gb); + edge.id = id; + edge.source_id = source_id; + edge.target_id = target_id; + edge.type = gbIntern(gb, type_c); + edge.properties_json = dupOrEmptyJson(properties_json); + + // Store pointer in array + gb.edges.push(edge); + + // Dedup index + _ = c.cbm_ht_set(gb.edge_by_key, c.strdup(&key), edge); + + // Secondary indexes + registerEdgeInIndexes(gb, edge); + + return id; +} + +pub export fn cbm_gbuf_find_edges_by_source_type(gb_c: ?*const Gbuf, source_id: i64, type_c: ?Str, out_c: ?*?[*]*const Edge, count_c: ?*c_int) c_int { + const gb = gb_c orelse return NOT_FOUND; + const out = out_c orelse return NOT_FOUND; + const count = count_c orelse return NOT_FOUND; + var key: [EDGE_KEY_BUF]u8 = undefined; + makeSrcTypeKey(&key, source_id, type_c); + returnArray(EdgePtrArray, htGet(EdgePtrArray, gb.edges_by_source_type, &key), out, count); + return 0; +} + +pub export fn cbm_gbuf_find_edges_by_target_type(gb_c: ?*const Gbuf, target_id: i64, type_c: ?Str, out_c: ?*?[*]*const Edge, count_c: ?*c_int) c_int { + const gb = gb_c orelse return NOT_FOUND; + const out = out_c orelse return NOT_FOUND; + const count = count_c orelse return NOT_FOUND; + var key: [EDGE_KEY_BUF]u8 = undefined; + makeSrcTypeKey(&key, target_id, type_c); + returnArray(EdgePtrArray, htGet(EdgePtrArray, gb.edges_by_target_type, &key), out, count); + return 0; +} + +pub export fn cbm_gbuf_find_edges_by_type(gb_c: ?*const Gbuf, type_c: ?Str, out_c: ?*?[*]*const Edge, count_c: ?*c_int) c_int { + const gb = gb_c orelse return NOT_FOUND; + const out = out_c orelse return NOT_FOUND; + const count = count_c orelse return NOT_FOUND; + returnArray(EdgePtrArray, htGet(EdgePtrArray, gb.edges_by_type, type_c), out, count); + return 0; +} + +pub export fn cbm_gbuf_edge_count(gb_c: ?*const Gbuf) c_int { + const gb = gb_c orelse return 0; + return gb.edges.count; +} + +pub export fn cbm_gbuf_edge_count_by_type(gb_c: ?*const Gbuf, type_c: ?Str) c_int { + const gb = gb_c orelse return 0; + if (type_c == null) return 0; + const arr = htGet(EdgePtrArray, gb.edges_by_type, type_c) orelse return 0; + return arr.count; +} + +pub export fn cbm_gbuf_delete_edges_by_type(gb_c: ?*Gbuf, type_c: ?Str) c_int { + const gb = gb_c orelse return NOT_FOUND; + if (type_c == null) return NOT_FOUND; + + // Remove edges of the given type from array and dedup index + const edges = gb.edges.slice(); + var write_idx: usize = 0; + for (edges) |e| { + if (streq(e.type, type_c)) { + var key: [EDGE_KEY_BUF]u8 = undefined; + makeEdgeKey(&key, e.source_id, e.target_id, e.type, e.properties_json); + const ekey = c.cbm_ht_get_key(gb.edge_by_key, &key); + _ = c.cbm_ht_delete(gb.edge_by_key, &key); + c.free(@constCast(ekey)); + freeEdgeStrings(e); + c.free(e); + } else { + edges[write_idx] = e; + write_idx += 1; + } + } + gb.edges.count = @intCast(write_idx); + + // Rebuild edge secondary indexes + rebuildEdgeSecondaryIndexes(gb); + + return 0; +} + +pub export fn cbm_gbuf_delete_edges_by_source_type(gb_c: ?*Gbuf, source_id: i64, type_c: ?Str) c_int { + const gb = gb_c orelse return NOT_FOUND; + if (type_c == null) return NOT_FOUND; + const edges = gb.edges.slice(); + var write_idx: usize = 0; + for (edges) |e| { + if (e.source_id == source_id and streq(e.type, type_c)) { + unindexEdge(gb, e); + freeEdgeStrings(e); + c.free(e); + } else { + edges[write_idx] = e; + write_idx += 1; + } + } + gb.edges.count = @intCast(write_idx); + rebuildEdgeSecondaryIndexes(gb); + return 0; +} + +pub export fn cbm_gbuf_delete_edges_by_type_unless(gb_c: ?*Gbuf, type_c: ?Str, keep_c: c.cbm_gbuf_edge_keep_fn, userdata: ?*anyopaque) c_int { + const gb = gb_c orelse return NOT_FOUND; + const keep = keep_c orelse return NOT_FOUND; + if (type_c == null) return NOT_FOUND; + const edges = gb.edges.slice(); + var write_idx: usize = 0; + for (edges) |e| { + if (streq(e.type, type_c) and !keep(@ptrCast(gb), e, userdata)) { + unindexEdge(gb, e); + freeEdgeStrings(e); + c.free(e); + } else { + edges[write_idx] = e; + write_idx += 1; + } + } + if (write_idx != edges.len) { + gb.edges.count = @intCast(write_idx); + rebuildEdgeSecondaryIndexes(gb); + } + return 0; +} + +// ── Merge ────────────────────────────────────────────────────────── + +/// Free remap hash table entries (key = heap string, value = heap i64*). +fn freeRemapEntry(key: [*c]const u8, val: ?*anyopaque, ud: ?*anyopaque) callconv(.c) void { + _ = ud; + c.free(@constCast(key)); + c.free(val); +} + +/// Handle QN collision: update dst node fields (src wins), record remap if IDs differ. +/// label/file_path are re-interned into dst's pool (sn's pointers belong to src). +fn mergeUpdateExisting(dst: *Gbuf, existing: *Node, sn: *const Node, remap: *?*Ht) void { + // Same guard as cbm_gbuf_upsert_node: a per-file "Module" def coming from a + // worker-local gbuf must not touch the structural directory node ("Project" + // root or "Folder") that shares its QN in a directory-based-module language + // (Java/Go). pass_structure seeds Folder/Project nodes on the MAIN gbuf + // before the parallel extract, so every worker's always-emitted Module def + // for that package collides here; unconditional "src wins" relabelled the + // directory node to Module and set its file_path to whichever worker merged + // LAST — the nondeterministic USAGE-source misattribution of #787. Keep the + // structural node intact; the ID remap below still redirects the worker's + // edges onto the canonical node. + if (!moduleOnContainer(existing.label, sn.label)) { + // Canonical collision winner (determinism) — mirrors + // cbm_gbuf_upsert_node exactly. Distinct source entities can share a + // QN (C: struct/function/macro with one name); unconditional "src + // wins" made the survivor depend on worker merge order, flickering + // the node set (and every downstream consumer) run to run. Winner = + // smallest file_path, then LARGEST start_line, then largest + // name/label — one total order, commutative, scheduling-free; a full + // tie is the same entity → refresh from src. A definition beats a + // macro ahead of all of these (gbQnClaimRank). + const sn_wins = collisionOrder(existing, sn.label, sn.name, sn.file_path, sn.start_line) <= 0; + if (sn_wins) { + // Keep the secondary indexes consistent when the surviving + // label/name changes (the old code left the node listed under its + // original label/name, so find_by_label/name mis-listed it). + const new_label = gbIntern(dst, sn.label); + const label_changed = existing.label == null or new_label == null or !streq(existing.label, new_label); + const name_changed = existing.name == null or sn.name == null or !streq(existing.name, sn.name); + if (label_changed) { + removeNodeFromPtrArray(htGet(NodePtrArray, dst.nodes_by_label, orEmpty(existing.label)), existing.id); + } + if (name_changed) { + removeNodeFromPtrArray(htGet(NodePtrArray, dst.nodes_by_name, orEmpty(existing.name)), existing.id); + } + existing.label = new_label; + c.free(existing.name); + existing.name = dupOrEmptyJson(sn.name); + existing.file_path = gbIntern(dst, sn.file_path); + existing.start_line = sn.start_line; + existing.end_line = sn.end_line; + if (sn.properties_json != null) { + c.free(existing.properties_json); + existing.properties_json = dupOrEmptyJson(sn.properties_json); + } + if (label_changed) { + if (getOrCreateArray(NodePtrArray, dst.nodes_by_label, orEmpty(existing.label))) |by_label| by_label.push(existing); + } + if (name_changed) { + if (getOrCreateArray(NodePtrArray, dst.nodes_by_name, orEmpty(existing.name))) |by_name| by_name.push(existing); + } + } + } + + if (sn.id != existing.id) { + if (remap.* == null) remap.* = c.cbm_ht_create(32); + var key: [ID_KEY_BUF]u8 = undefined; + makeIdKey(&key, sn.id); + const val: *i64 = @ptrCast(@alignCast(c.malloc(@sizeOf(i64)) orelse return)); + val.* = existing.id; + _ = c.cbm_ht_set(remap.*, c.strdup(&key), val); + } +} + +/// Copy a non-colliding src node into dst with its original ID. +fn mergeCopyNewNode(dst: *Gbuf, sn: *const Node) void { + const node: *Node = @ptrCast(@alignCast(c.calloc(1, @sizeOf(Node)) orelse return)); + + node.id = sn.id; + node.label = gbIntern(dst, sn.label); + node.name = dupOrEmptyJson(sn.name); + node.qualified_name = dupOrEmptyJson(sn.qualified_name); + node.file_path = gbIntern(dst, sn.file_path); + node.start_line = sn.start_line; + node.end_line = sn.end_line; + node.properties_json = dupOrEmptyJson(sn.properties_json); + + dst.nodes.push(node); + registerNodeInIndexes(dst, node); + + if (node.id >= dst.next_id) dst.next_id = node.id + 1; +} + +/// The collision remap's replacement for `id`, or `id` itself. +fn remappedId(remap: ?*Ht, id: i64) i64 { + var key: [ID_KEY_BUF]u8 = undefined; + makeIdKey(&key, id); + const remapped = htGet(i64, remap, &key) orelse return id; + return remapped.*; +} + +/// Remap edge IDs using the collision remap table and insert into dst. +fn mergeRemapEdges(dst: *Gbuf, src: *Gbuf, remap: ?*Ht) void { + for (src.edges.slice()) |se| { + var new_src = se.source_id; + var new_tgt = se.target_id; + + if (remap != null) { + new_src = remappedId(remap, se.source_id); + new_tgt = remappedId(remap, se.target_id); + } + + _ = cbm_gbuf_insert_edge(dst, new_src, new_tgt, se.type, se.properties_json); + } +} + +pub export fn cbm_gbuf_merge(dst_c: ?*Gbuf, src_c: ?*Gbuf) c_int { + const dst = dst_c orelse return NOT_FOUND; + const src = src_c orelse return NOT_FOUND; + if (src.nodes.count == 0 and src.edges.count == 0) return 0; + + // ID remap for QN-colliding nodes: "src_id" → (i64*) dst_id. + // Only populated when a src node's QN already exists in dst. + var remap: ?*Ht = null; + + for (src.nodes.slice()) |sn| { + // Skip nodes deleted from QN index + if (!src.isLive(sn)) continue; + + if (htGet(Node, dst.node_by_qn, sn.qualified_name)) |existing| { + mergeUpdateExisting(dst, existing, sn, &remap); + } else { + mergeCopyNewNode(dst, sn); + } + } + + // Merge edges with optional ID remapping + mergeRemapEdges(dst, src, remap); + + if (remap != null) freeIndex(remap, freeRemapEntry); + + return 0; +} + +// ── Dump / Flush ─────────────────────────────────────────────────── + +/// Extract a string property from a properties JSON string. +/// Returns heap-allocated string or NULL. Caller must free. +/// Parses real JSON: the dump writer feeds these values into indexes whose +/// backing columns are GENERATED AS json_extract(properties,'$.'). +/// Naive byte slicing returned the ESCAPED text (and cut at embedded \") +/// while json_extract yields the unescaped value — the mismatch left rows +/// "missing from index idx_edges_url_path" under PRAGMA integrity_check. +/// key_quoted ("\"key\"") is a fast pre-filter to skip the JSON parse. +fn extractPropString(props: CStr, key_quoted: Str, key: Str) [*c]u8 { + if (props == null or c.strstr(props, key_quoted) == null) return null; + const doc = c.yyjson_read(props, c.strlen(props), 0) orelse return null; + defer c.yyjson_doc_free(doc); + const v = c.yyjson_obj_get(c.yyjson_doc_get_root(doc), key); + if (v == null or !c.yyjson_is_str(v)) return null; + const sv = c.yyjson_get_str(v); + return c.cbm_strndup(sv, c.strlen(sv)); +} + +fn extractUrlPath(props: CStr) [*c]u8 { + return extractPropString(props, "\"url_path\"", "url_path"); +} + +/// local_name feeds the hand-built sqlite_autoindex_edges_1 — its backing +/// column local_name_gen is GENERATED only for IMPORTS edges (#768). +fn extractLocalName(props: CStr) [*c]u8 { + return extractPropString(props, "\"local_name\"", "local_name"); +} + +/// Remap a temp ID to its final sequential ID, or 0 if out of range. +fn remapId(temp_to_final: []const i64, temp_id: i64) i64 { + return if (temp_id >= 0 and temp_id < temp_to_final.len) temp_to_final[@intCast(temp_id)] else 0; +} + +fn cmpDumpVectorsById(a: ?*const anyopaque, b: ?*const anyopaque) callconv(.c) c_int { + const da = @as(*const c.CBMDumpVector, @ptrCast(@alignCast(a))).node_id; + const db = @as(*const c.CBMDumpVector, @ptrCast(@alignCast(b))).node_id; + return @as(c_int, @intFromBool(da > db)) - @as(c_int, @intFromBool(da < db)); +} + +/// Build dump-ready node array with sequential IDs. Populates temp_to_final +/// mapping. NULL on OOM. +fn buildDumpNodes(gb: *Gbuf, live_count: c_int, temp_to_final: []i64) ?[]c.CBMDumpNode { + const cap: usize = @intCast(if (live_count > 0) live_count else 1); + const dump_nodes = callocSlice(c.CBMDumpNode, cap) orelse return null; + var idx: usize = 0; + + for (gb.nodes.slice()) |n| { + if (!gb.isLive(n)) continue; + + const final_id: i64 = @intCast(idx + 1); // 1-based sequential + if (n.id >= 0 and n.id < temp_to_final.len) temp_to_final[@intCast(n.id)] = final_id; + + dump_nodes[idx] = .{ + .id = final_id, + .project = gb.project, + .label = n.label, + .name = n.name, + .qualified_name = n.qualified_name, + .file_path = orEmpty(n.file_path), + .start_line = n.start_line, + .end_line = n.end_line, + .properties = if (n.properties_json != null) n.properties_json else "{}", + }; + idx += 1; + } + + return dump_nodes[0..idx]; +} + +/// Build dump-ready edge array with remapped IDs. Each edge's url_path and +/// local_name are heap strings the caller owns (free with +/// freeRetainedDumpEdges). NULL on OOM. +fn buildDumpEdges(gb: *Gbuf, temp_to_final: []const i64) ?[]c.CBMDumpEdge { + // Count valid edges (both endpoints resolved) + var valid_edges: usize = 0; + for (gb.edges.slice()) |e| { + if (remapId(temp_to_final, e.source_id) > 0 and remapId(temp_to_final, e.target_id) > 0) valid_edges += 1; + } + + const dump_edges = callocSlice(c.CBMDumpEdge, @max(valid_edges, 1)) orelse return null; + var idx: usize = 0; + + for (gb.edges.slice()) |e| { + const src = remapId(temp_to_final, e.source_id); + const tgt = remapId(temp_to_final, e.target_id); + if (src == 0 or tgt == 0) continue; + + const url_path = extractUrlPath(e.properties_json); + + // IMPORTS only — mirrors the local_name_gen CASE in the edges DDL. + const local_name: [*c]u8 = if (e.type != null and streq(e.type, "IMPORTS")) extractLocalName(e.properties_json) else null; + + dump_edges[idx] = .{ + .id = @intCast(idx + 1), + .project = gb.project, + .source_id = src, + .target_id = tgt, + .type = e.type, + .properties = if (e.properties_json != null) e.properties_json else "{}", + .url_path = url_path, + .local_name = local_name, + }; + idx += 1; + } + + return dump_edges[0..idx]; +} + +/// Remap vector node IDs through temp_to_final, sort by ID, deduplicate. +fn remapSortDedupVectors(gb: *Gbuf, temp_to_final: []const i64) void { + const vectors = gb.dumpVectors(); + var remapped: usize = 0; + var dropped: c_int = 0; + for (vectors) |v| { + const new_id = if (v.node_id > 0) remapId(temp_to_final, v.node_id) else 0; + if (new_id > 0) { + vectors[remapped] = v; + vectors[remapped].node_id = new_id; + remapped += 1; + } else { + dropped += 1; + } + } + if (dropped > 0) { + var r_buf: [16]u8 = undefined; + var d_buf: [16]u8 = undefined; + interop.logInfo("dump.vectors.remap", .{ "remapped", fmtInt(&r_buf, @intCast(remapped)), "dropped", fmtInt(&d_buf, dropped) }); + } + gb.dump_vector_count = @intCast(remapped); + + if (remapped >= GB_MIN_FOR_DEDUP) { + const kept = vectors[0..remapped]; + c.qsort(kept.ptr, kept.len, @sizeOf(c.CBMDumpVector), cmpDumpVectorsById); + var deduped: usize = 0; + for (kept, 0..) |v, i| { + if (i + GB_DEDUP_LOOKAHEAD < kept.len and v.node_id == kept[i + GB_DEDUP_LOOKAHEAD].node_id) continue; + kept[deduped] = v; + deduped += 1; + } + gb.dump_vector_count = @intCast(deduped); + } +} + +fn logDumpSummary(node_count: usize, edge_count: usize) void { + var b1: [16]u8 = undefined; + var b2: [16]u8 = undefined; + interop.logInfo("gbuf.dump", .{ "nodes", fmtInt(&b1, @intCast(node_count)), "edges", fmtInt(&b2, @intCast(edge_count)) }); +} + +fn freeRetainedDumpEdges(edges_opt: ?[*]c.CBMDumpEdge, edge_count: c_int) void { + const edges = edges_opt orelse return; + for (edges[0..@intCast(edge_count)]) |e| { + c.free(@constCast(e.url_path)); + c.free(@constCast(e.local_name)); + } + c.free(@ptrCast(edges)); +} + +/// Give every edge an owned url_path and local_name: the extracted value, or +/// an owned "" where the property was absent. On OOM the strings are freed +/// and the caller frees the array. +fn retainDumpEdgeStrings(edges: []c.CBMDumpEdge) bool { + for (edges, 0..) |*e, i| { + if (e.url_path == null) e.url_path = c.strdup(""); + if (e.local_name == null) e.local_name = c.strdup(""); + if (e.url_path == null or e.local_name == null) { + for (edges[0 .. i + 1]) |done| { + c.free(@constCast(done.url_path)); + c.free(@constCast(done.local_name)); + } + for (edges[i + 1 ..]) |rest| { + c.free(@constCast(rest.url_path)); + c.free(@constCast(rest.local_name)); + } + return false; + } + } + return true; +} + +fn countLiveNodes(gb: *Gbuf) c_int { + var count: c_int = 0; + for (gb.nodes.slice()) |n| count += @intFromBool(gb.isLive(n)); + return count; +} + +fn generateIsoTimestamp(buf: []u8) void { + var now: c.time_t = c.time(null); + var tm_buf: c.struct_tm = undefined; + const tm_val = c.cbm_gmtime_r(&now, &tm_buf); + if (c.strftime(buf.ptr, buf.len, "%Y-%m-%dT%H:%M:%SZ", tm_val) == 0) { + _ = interop.bprint(buf, "1970-01-01T00:00:00Z", .{}); + } +} + +/// CBM_PROF_START / CBM_PROF_END / CBM_PROF_END_N from foundation/profile.h. +const Prof = struct { + start: c.struct_timespec = undefined, + + fn begin() Prof { + var prof: Prof = .{}; + if (c.cbm_profile_active) c.cbm_profile_now(&prof.start); + return prof; + } + + fn end(prof: *const Prof, phase: Str, sub: Str, items: c_long) void { + if (c.cbm_profile_active) c.cbm_profile_log_elapsed(phase, sub, &prof.start, items); + } +}; + +/// Build the final node, edge and vector arrays published to cbm.zova. +fn gbufBuildDump(gb_c: ?*Gbuf) c_int { + const gb = gb_c orelse return NOT_FOUND; + + const t_count = Prof.begin(); + const live_count = countLiveNodes(gb); + t_count.end("dump", "1_count_live_nodes", live_count); + + const t_build_nodes = Prof.begin(); + const max_temp_id: usize = @intCast(gb.next_id); + const temp_to_final = callocSlice(i64, max_temp_id) orelse return NOT_FOUND; + defer c.free(temp_to_final.ptr); + + const dump_nodes = buildDumpNodes(gb, live_count, temp_to_final) orelse return NOT_FOUND; + t_build_nodes.end("dump", "2_build_dump_nodes", @intCast(dump_nodes.len)); + + // Release ALL lookup indexes NOW: nothing between here and finalize reads + // them (buildDumpEdges uses temp_to_final + gb.edges, the vector remap + // uses temp_to_final). At kernel scale they are ~3.8 GB (hash buckets + + // key strings + pointer arrays); releasing them only AFTER edge building + // made them coexist with every dump-side transient array — the dump-phase + // RSS peak. + const t_release_idx = Prof.begin(); + releaseGbufIndexes(gb); + t_release_idx.end("dump", "4_release_gbuf_indexes", 0); + + generateIsoTimestamp(&gb.indexed_at); + + const t_build_edges = Prof.begin(); + const dump_edges = buildDumpEdges(gb, temp_to_final) orelse { + c.free(dump_nodes.ptr); + return NOT_FOUND; + }; + t_build_edges.end("dump", "3_build_dump_edges", @intCast(dump_edges.len)); + + // Remap+sort+dedup vectors to the final node ids. + const t_vec_remap = Prof.begin(); + remapSortDedupVectors(gb, temp_to_final); + t_vec_remap.end("dump", "5_vector_remap_sort", gb.dump_vector_count); + + const retained = retainDumpEdgeStrings(dump_edges); + logDumpSummary(dump_nodes.len, dump_edges.len); + if (!retained) { + c.free(dump_edges.ptr); + c.free(dump_nodes.ptr); + return NOT_FOUND; + } + + c.free(@ptrCast(gb.zova_dump_nodes)); + freeRetainedDumpEdges(gb.zova_dump_edges, gb.zova_dump_edge_count); + gb.zova_dump_nodes = dump_nodes.ptr; + gb.zova_dump_node_count = @intCast(dump_nodes.len); + gb.zova_dump_edges = dump_edges.ptr; + gb.zova_dump_edge_count = @intCast(dump_edges.len); + return 0; +} + +/// The generation described by the prepared dump arrays. +fn generationInput(gb: *const Gbuf, indexed_at: CStr) c.cbm_zova_workspace_generation_input_t { + return .{ + .root_path = gb.root_path, + .project = gb.project, + .indexed_at = indexed_at, + .model_fingerprint = c.CBM_ZOVA_MODEL_FINGERPRINT, + .vector_dimensions = c.CBM_SEM_DIM, + .nodes = gb.zova_dump_nodes, + .node_count = gb.zova_dump_node_count, + .edges = gb.zova_dump_edges, + .edge_count = gb.zova_dump_edge_count, + .node_vectors = gb.dump_vectors, + .node_vector_count = gb.dump_vector_count, + .token_vectors = gb.dump_token_vecs, + .token_vector_count = gb.dump_token_vec_count, + }; +} + +/// generationInput plus the file hashes and project summary, stamped with the +/// dump's timestamp (or a fresh one when no dump was prepared). +fn publishInput(gb: *const Gbuf, indexed_at: *[64]u8, file_hashes: ?[*]const c.cbm_zova_file_hash_input_t, file_hash_count: c_int, project_summary: ?*const c.cbm_zova_project_summary_input_t) c.cbm_zova_workspace_generation_input_t { + if (gb.indexed_at[0] != 0) { + indexed_at.* = gb.indexed_at; + } else { + generateIsoTimestamp(indexed_at); + } + var input = generationInput(gb, indexed_at); + input.file_hashes = file_hashes; + input.file_hash_count = file_hash_count; + if (project_summary) |summary| input.project_summary = summary.*; + return input; +} + +pub export fn cbm_gbuf_prepare_zova_dump(gb_c: ?*Gbuf) c_int { + const rc = gbufBuildDump(gb_c); + if (rc != 0) return rc; + const gb = gb_c.?; + var workspace_id: [c.CBM_ZOVA_WORKSPACE_ID_MAX]u8 = undefined; + const input = generationInput(gb, &gb.indexed_at); + var view: ?*c.cbm_zova_prepared_view_t = null; + if (c.cbm_zova_workspace_id_for_root(gb.root_path, &workspace_id, workspace_id.len) != 0 or + c.cbm_zova_prepared_view_build_base(&workspace_id, &input, &view) != 0) + { + return NOT_FOUND; + } + c.cbm_zova_prepared_view_free(gb.zova_prepared_view); + gb.zova_prepared_view = view; + return 0; +} + +pub export fn cbm_gbuf_prepare_zova_dump_delta(gb: ?*Gbuf) c_int { + return gbufBuildDump(gb); +} + +pub export fn cbm_gbuf_publish_zova_user_database(gb_c: ?*Gbuf, file_hashes: ?[*]const c.cbm_zova_file_hash_input_t, file_hash_count: c_int, project_summary: ?*const c.cbm_zova_project_summary_input_t, out_result: ?*c.cbm_zova_workspace_generation_result_t) c_int { + const gb = gb_c orelse return NOT_FOUND; + if (out_result == null or file_hash_count < 0 or (file_hash_count > 0 and file_hashes == null)) { + return NOT_FOUND; + } + var user_database_path: [c.CBM_SZ_1K]u8 = undefined; + if (c.cbm_zova_workspace_registry_path(&user_database_path, user_database_path.len) != 0) { + return NOT_FOUND; + } + var indexed_at: [64]u8 = undefined; + const input = publishInput(gb, &indexed_at, file_hashes, file_hash_count, project_summary); + var workspace_id: [c.CBM_ZOVA_WORKSPACE_ID_MAX]u8 = undefined; + var view = gb.zova_prepared_view; + var owned_view = false; + var rc = c.cbm_zova_workspace_id_for_root(input.root_path, &workspace_id, workspace_id.len); + if (rc == 0 and view == null) { + rc = c.cbm_zova_prepared_view_build(&workspace_id, &input, &view); + owned_view = rc == 0; + } else if (rc == 0) { + rc = c.cbm_zova_prepared_view_complete(view, file_hashes, file_hash_count, project_summary); + } + if (rc == 0) rc = c.cbm_zova_user_database_publish_prepared_view(&user_database_path, view, out_result); + if (owned_view) c.cbm_zova_prepared_view_free(view); + return rc; +} + +pub export fn cbm_gbuf_publish_zova_user_database_delta(gb_c: ?*const Gbuf, before_c: ?*c.cbm_zova_workspace_snapshot_t, file_hashes: ?[*]const c.cbm_zova_file_hash_input_t, file_hash_count: c_int, project_summary: ?*const c.cbm_zova_project_summary_input_t, out_result: ?*c.cbm_zova_workspace_generation_result_t) c_int { + const gb = gb_c orelse return NOT_FOUND; + const before = before_c orelse return NOT_FOUND; + if (out_result == null or file_hash_count < 0 or (file_hash_count > 0 and file_hashes == null)) { + return NOT_FOUND; + } + var user_database_path: [c.CBM_SZ_1K]u8 = undefined; + if (c.cbm_zova_workspace_registry_path(&user_database_path, user_database_path.len) != 0) { + return NOT_FOUND; + } + var indexed_at: [64]u8 = undefined; + const input = publishInput(gb, &indexed_at, file_hashes, file_hash_count, project_summary); + var workspace_id: [c.CBM_ZOVA_WORKSPACE_ID_MAX]u8 = undefined; + var model: ?*c.cbm_zova_publish_model_t = null; + var delta: ?*c.cbm_zova_workspace_delta_t = null; + var rc = c.cbm_zova_workspace_id_for_root(input.root_path, &workspace_id, workspace_id.len); + if (rc == 0 and c.strcmp(&workspace_id, &before.workspace_id) != 0) rc = -1; + if (rc == 0) rc = c.cbm_zova_publish_model_build(&workspace_id, &input, &model); + if (rc == 0 and gb.reuse_zova_semantic_vectors) { + rc = c.cbm_zova_publish_model_reuse_vector_digests(model, &before.integrity, c.CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS | + c.CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS); + } + var required: c.cbm_zova_snapshot_components_t = c.CBM_ZOVA_SNAPSHOT_COMPONENT_NONE; + if (rc == 0) rc = c.cbm_zova_workspace_delta_required_components(before, model, &required); + if (rc == 0 and required != c.CBM_ZOVA_SNAPSHOT_COMPONENT_NONE) { + rc = c.cbm_zova_repository_hydrate_incremental_components(&user_database_path, &workspace_id, before.generation, required, before); + } + if (rc == 0) rc = c.cbm_zova_workspace_delta_build(before, model, &delta); + if (rc == 0) rc = c.cbm_zova_user_database_publish_delta(&user_database_path, model, delta, out_result); + c.cbm_zova_workspace_delta_free(delta); + c.cbm_zova_publish_model_free(model); + return rc; +} + +/// Upsert every live node and insert every edge whose endpoints were written, +/// mapping buffer ids to the ids the store assigned. +fn writeToStore(gb: *Gbuf, store: ?*c.cbm_store_t, temp_to_real: []i64) void { + for (gb.nodes.slice()) |n| { + // Skip if deleted from QN index + if (!gb.isLive(n)) continue; + + const sn: c.cbm_node_t = .{ + .project = gb.project, + .label = n.label, + .name = n.name, + .qualified_name = n.qualified_name, + .file_path = n.file_path, + .start_line = n.start_line, + .end_line = n.end_line, + .properties_json = n.properties_json, + }; + const real_id = c.cbm_store_upsert_node(store, &sn); + if (real_id > 0 and n.id >= 0 and n.id < temp_to_real.len) temp_to_real[@intCast(n.id)] = real_id; + } + + // Insert all edges with remapped IDs + for (gb.edges.slice()) |e| { + const real_src = remapId(temp_to_real, e.source_id); + const real_tgt = remapId(temp_to_real, e.target_id); + if (real_src == 0 or real_tgt == 0) continue; + + const se: c.cbm_edge_t = .{ + .project = gb.project, + .source_id = real_src, + .target_id = real_tgt, + .type = e.type, + .properties_json = e.properties_json, + }; + _ = c.cbm_store_insert_edge(store, &se); + } +} + +/// temp_id → real_id map. Temp IDs start at 1 and are sequential, but can +/// have gaps from edge inserts, so the map is sized by next_id. +fn allocTempToReal(gb: *const Gbuf) ?[]i64 { + return callocSlice(i64, @intCast(gb.next_id)); +} + +pub export fn cbm_gbuf_flush_to_store(gb_c: ?*Gbuf, store: ?*c.cbm_store_t) c_int { + const gb = gb_c orelse return NOT_FOUND; + if (store == null) return NOT_FOUND; + const temp_to_real = allocTempToReal(gb) orelse return NOT_FOUND; + defer c.free(temp_to_real.ptr); + + // Upsert project + _ = c.cbm_store_upsert_project(store, gb.project, gb.root_path); + + // Begin bulk mode + _ = c.cbm_store_begin_bulk(store); + _ = c.cbm_store_drop_indexes(store); + _ = c.cbm_store_begin(store); + + // Delete existing project data + _ = c.cbm_store_delete_edges_by_project(store, gb.project); + _ = c.cbm_store_delete_nodes_by_project(store, gb.project); + + writeToStore(gb, store, temp_to_real); + + _ = c.cbm_store_commit(store); + _ = c.cbm_store_create_indexes(store); + _ = c.cbm_store_end_bulk(store); + return 0; +} + +pub export fn cbm_gbuf_merge_into_store(gb_c: ?*Gbuf, store: ?*c.cbm_store_t) c_int { + const gb = gb_c orelse return NOT_FOUND; + if (store == null) return NOT_FOUND; + const temp_to_real = allocTempToReal(gb) orelse return NOT_FOUND; + defer c.free(temp_to_real.ptr); + + // Begin bulk mode — no project wipe + _ = c.cbm_store_begin(store); + writeToStore(gb, store, temp_to_real); + _ = c.cbm_store_commit(store); + return 0; +} From d11727e5f0bb3a0cbdbb80dcb83dcf3f42afc0e0 Mon Sep 17 00:00:00 2001 From: ata-sesli Date: Tue, 10 Nov 2026 20:47:09 +0300 Subject: [PATCH 2/9] refactor(store): port src/store from C to Zig store.c (8,553 lines) becomes store.zig (6,913 lines). store.h stays the ABI: all 108 exported functions keep their names and signatures, and checkExports verifies them against the header. Layout follows the C file section by section: open/close and schema, projects, nodes, edges, search, BFS and trail traversal, file hashes, the architecture aspects, Louvain/Leiden clustering, and vector search (SQLite and Zova sessions). Row collection, statement preparation and string duplication go through a few small helpers (Rows, prepareOne, columnDup, ArchScope) instead of the repeated macro/realloc blocks. qsort/bsearch are still the libc ones with C comparators, so result ordering is unchanged. vs_fill_sparse_random uses std.hash.XxHash3, which matches the vendored XXH3_64bits_withSeed (checked against the C implementation). Behavior differences: - WHERE-clause building clamps to the buffer; C accumulated the untruncated snprintf length. - Exclude-label binds stop at ST_SEARCH_MAX_BINDS; C wrote past a 32-entry stack array. - NULL store in cbm_store_get_project, find_nodes_by_file_overlap, find_edges_by_url_path, count_edges_by_type, cbm_store_find_architecture_docs and cbm_store_get_architecture returns CBM_STORE_ERR; C dereferenced it. - cbm_glob_to_like returns NULL on allocation failure. - The typed Zova BFS trim loop stops at 0 hops for max_results < 0; C read hops[-1]. - cbm_store_bfs_trail returns CBM_STORE_ERR when its stack allocation fails; the trail row limit uses saturating 64-bit math. - cbm_deduplicate_hops returns OK for hop_count <= 0 and ERR on allocation failure. - cbm_store_normalize_arch_path returns false for a NULL or zero-size buffer. NULL edge_types with a positive count means "no types". - Leiden and cluster building: allocation failures that C left unchecked (weights, cluster arrays, top_nodes/packages/edge_types) now yield no clusters or singleton communities. Cluster package names are moved into the result instead of copied and freed. - fetch_limit = limit * 5 in vector search saturates. - The SQL branch after `if (s->zova_workspace_query) return ERR` in cbm_store_vector_search_ex was unreachable and is gone. - cbm_store_architecture_free tolerates NULL arrays with non-zero counts. Not verified: vs_normalize_and_quantize sums v*v without FMA contraction. If clang fused that sum on aarch64, the last bit of the magnitude (and rarely one int8 component) can differ from the C build. Wiring: Makefile.cbm drops STORE_SRCS from PROD_SRCS and LINT_SRCS and adds the Zig file and header to CBM_ZIG_SRCS; build.zig drops the C file; checkExports raises its comptime branch quota to 50000 for the 108 store exports. tests/test_github_workflows.sh names store.zig in its lstat check. scripts/security-audit.sh drops the store.c entry from the fopen known-safe list: store.c had no fopen call, so the entry was already stale, and store.zig has none either. Audit counts are unchanged. Signed-off-by: ata-sesli --- Makefile.cbm | 8 +- build.zig | 1 - scripts/security-audit.sh | 2 +- src/cbm_zig.zig | 3 +- src/store/store.c | 8553 -------------------------------- src/store/store.zig | 6913 ++++++++++++++++++++++++++ tests/test_github_workflows.sh | 2 +- 7 files changed, 6920 insertions(+), 8562 deletions(-) delete mode 100644 src/store/store.c create mode 100644 src/store/store.zig diff --git a/Makefile.cbm b/Makefile.cbm index 5b353bb9..3acad655 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -149,9 +149,6 @@ PREPROCESSOR_SRC = $(CBM_DIR)/preprocessor.cpp # SQLite writer -# Store module (new) -STORE_SRCS = src/store/store.c - # Optional Zova storage adapter. Compiles to no-op stubs unless # CBM_WITH_ZOVA=1 is set for the build. ZOVA_SRCS = \ @@ -266,7 +263,7 @@ TRE_CFLAGS = -std=c11 -g -O1 -w -Ivendored/tre YYJSON_SRC = vendored/yyjson/yyjson.c # All production sources -PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(ZOVA_SRCS) $(MCP_SRCS) $(PIPELINE_SRCS) $(SEMANTIC_SRCS) $(CLI_SRCS) $(YYJSON_SRC) +PROD_SRCS = $(FOUNDATION_SRCS) $(ZOVA_SRCS) $(MCP_SRCS) $(PIPELINE_SRCS) $(SEMANTIC_SRCS) $(CLI_SRCS) $(YYJSON_SRC) EXISTING_C_SRCS = $(EXTRACTION_SRCS) $(LSP_SRCS) $(TS_RUNTIME_SRC) \ $(GRAMMAR_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) @@ -493,6 +490,7 @@ CBM_ZIG_SRCS = $(CBM_ZIG_SRC) src/cbm_zig_c.h src/cli_zig/cbm_cli_zig.zig src/cl src/git/git_context.zig src/git/git_context.h \ src/graph_buffer/graph_buffer.zig src/graph_buffer/graph_buffer.h \ src/simhash/minhash.zig src/simhash/minhash.h \ + src/store/store.zig src/store/store.h \ src/watcher/watcher.zig src/watcher/watcher.h CBM_ZIG_C_INCLUDES = -Isrc -Ivendored -Ivendored/sqlite3 -Ivendored/mimalloc/include \ -I$(CBM_DIR) -I$(TS_INCLUDE) @@ -819,7 +817,7 @@ SYSROOT = $(shell xcrun --show-sdk-path 2>/dev/null) SYSROOT_FLAG = $(if $(SYSROOT),-isysroot $(SYSROOT),) # Our source files (excluding vendored, grammars, tree-sitter runtime) -LINT_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(MCP_SRCS) \ +LINT_SRCS = $(FOUNDATION_SRCS) $(MCP_SRCS) \ $(PIPELINE_SRCS) $(SEMANTIC_SRCS) \ $(CLI_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) \ $(ZSTD_SRCS) $(MAIN_SRC) diff --git a/build.zig b/build.zig index eaa591d5..f9abeae3 100644 --- a/build.zig +++ b/build.zig @@ -532,7 +532,6 @@ fn grammarSources(b: *std.Build) []const []const u8 { const native_sources = [_][]const u8{ "src/foundation/hash_table.c", "src/foundation/c_shim.c", - "src/store/store.c", "src/zova/cbm_zova.c", "src/zova/cbm_zova_publish_model.c", "src/zova/cbm_zova_edge_payload.c", diff --git a/scripts/security-audit.sh b/scripts/security-audit.sh index 2d27d35b..4814664b 100755 --- a/scripts/security-audit.sh +++ b/scripts/security-audit.sh @@ -171,7 +171,7 @@ while IFS= read -r match; do file=$(echo "$match" | cut -d: -f1) relfile="${file#"$ROOT/"}" case "$relfile" in - src/cli/cli.zig|src/store/store.c|src/pipeline/*.c|src/foundation/diagnostics.zig|src/mcp/search_tools.zig|src/mcp/index_tools.zig) + src/cli/cli.zig|src/pipeline/*.c|src/foundation/diagnostics.zig|src/mcp/search_tools.zig|src/mcp/index_tools.zig) ;; # Known safe (diagnostics.zig: atomic .tmp+rename metrics dump to configured path; # search_tools.zig: search pattern/file-list temp files; index_tools.zig: # index supervisor response/quarantine files) diff --git a/src/cbm_zig.zig b/src/cbm_zig.zig index 156ebafe..8993560c 100644 --- a/src/cbm_zig.zig +++ b/src/cbm_zig.zig @@ -62,13 +62,14 @@ comptime { checkExports(@import("mcp/search_tools.zig")); checkExports(@import("mcp/server.zig")); checkExports(@import("simhash/minhash.zig")); + checkExports(@import("store/store.zig")); checkExports(@import("watcher/watcher.zig")); } /// Checks every pub `cbm_*` function of `module` against the C declaration /// of the same name in the translated headers. fn checkExports(comptime module: type) void { - @setEvalBranchQuota(10000); + @setEvalBranchQuota(50000); for (@typeInfo(module).@"struct".decl_names) |name| { if (!std.mem.startsWith(u8, name, "cbm_")) continue; const Impl = @TypeOf(@field(module, name)); diff --git a/src/store/store.c b/src/store/store.c deleted file mode 100644 index 3690e03f..00000000 --- a/src/store/store.c +++ /dev/null @@ -1,8553 +0,0 @@ -/* - * store.c — SQLite graph store implementation. - * - * Implements the opaque cbm_store_t handle with prepared statement caching, - * schema initialization, and all CRUD operations for nodes, edges, projects, - * file hashes, search, BFS traversal, and schema introspection. - */ - -// for ISO timestamp - -#include -#include "foundation/constants.h" - -#include -#include - -enum { - ST_COL_1 = 1, - ST_COL_2 = 2, - ST_COL_3 = 3, - ST_COL_4 = 4, - ST_COL_5 = 5, - ST_COL_6 = 6, - ST_COL_7 = 7, - ST_COL_8 = 8, - ST_COL_9 = 9, - ST_FOUND = -1, - ST_BUF_16 = 16, - ST_BUF_64 = 64, - /* file: URI for the immutable read-only fallback. A path is at most - * CBM_SZ_1K; percent-encoding can triple it, plus the "file://" prefix, - * the "?immutable=1" suffix and a leading '/'. */ - ST_QUERY_URI_MAX = 3 * 1024 + 64, - /* Matches the PRAGMA busy_timeout configure_pragmas applies. */ - ST_BUSY_TIMEOUT_MS = 10000, - ST_GROWTH = 2, - ST_MAX_DEGREE = 8192, - ST_HALF_SEC = 500000, - ST_RETRY_WAIT_US = 1000, - ST_INIT_CAP_8 = 8, - ST_INIT_CAP_16 = 16, - ST_SQL_BUF = 8192, - ST_MAX_ROW_CHECK = 5, - ST_QN_MAX_DOTS = 5, - ST_QN_MIN_DOTS = 3, - ST_IN_CLAUSE_MARGIN = 4, - ST_GLOB_MIN_LEN = 3, - ST_GLOB_SKIP = 2, - ST_MAX_LANG = 10, - ST_SEARCH_MAX_BINDS = 32, /* increased: LIKE pre-filter adds binds per pattern */ - ST_LIKE_POOL_MAX = 12, /* max malloc'd LIKE strings alive during one search */ - ST_LIKE_HINT_MAX = 2, /* max LIKE hints extracted per regex pattern */ - ST_MAX_PKGS = 64, - ST_INIT_CAP_4 = 4, - ST_HEADER_PREFIX = 3, - ST_MIN_INDEGREE = 3, - ST_MAX_PATH_DEPTH = 3, - ST_MAX_ITERATIONS = 10, - ST_MAX_SECTIONS = 16, - ST_METHOD_PROP_LEN = 8, - ST_PATH_PROP_LEN = 6, - ST_HANDLER_PROP_LEN = 9, -}; - -#define SLEN(s) (sizeof(s) - 1) -#include "store/store.h" -#include "zova/cbm_zova.h" -#include "zova/cbm_zova_repository.h" -#include "foundation/platform.h" -#include "foundation/compat.h" -#include "foundation/log.h" -#include "foundation/compat_regex.h" -#include "foundation/str_util.h" - -#define XXH_INLINE_ALL -#include "xxhash/xxhash.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -/* ── SQLite bind helpers ───────────────────────────────────────── */ - -/* Isolate the int-to-ptr cast that SQLITE_TRANSIENT expands to. - A union type-pun avoids the performance-no-int-to-ptr diagnostic. */ -static sqlite3_destructor_type make_transient(void) { - union { - uintptr_t i; - sqlite3_destructor_type fn; - } u; - u.i = (uintptr_t)CBM_NOT_FOUND; - return u.fn; -} -#define BIND_TRANSIENT (make_transient()) - -static int bind_text(sqlite3_stmt *s, int col, const char *v) { - return sqlite3_bind_text(s, col, v, CBM_NOT_FOUND, BIND_TRANSIENT); -} - -/* ── Internal store structure ───────────────────────────────────── */ - -/* One cbm edge of a Zova workspace store: Zova node keys, which are also the - * ids of the nodes view, and an index into the store's edge type names. */ -typedef struct { - int64_t source_key; - int64_t target_key; - int type; -} store_zova_edge_t; - -struct cbm_store { - sqlite3 *db; - const char *db_path; /* heap-allocated, or NULL for :memory: */ - /* Set only by cbm_store_open_backend; db is NULL then. */ - const cbm_store_backend_t *backend; - void *backend_ctx; - char errbuf[CBM_SZ_512]; - - /* Read-only Zova sidecar session. One store is single-thread-owned. */ - cbm_zova_vector_session_t *zova_vector_session; - char *zova_vector_session_path; - struct stat zova_vector_session_stat; - bool zova_vector_session_stat_valid; - - /* Read-only Zova graph session. Separate from vectors so either sidecar - * capability can be unavailable without disturbing the other. */ - cbm_zova_graph_session_t *zova_graph_session; - char *zova_graph_workspace_root; - char zova_graph_workspace_id[CBM_ZOVA_WORKSPACE_ID_MAX]; - int64_t zova_graph_cached_numeric_id; - char zova_graph_cached_stable_id[CBM_ZOVA_WORKSPACE_ID_MAX + 96]; - bool zova_graph_cached_stable_id_valid; - bool zova_workspace_query; - cbm_zova_repository_t *zova_repository; - /* Legacy nodes view over the workspace; see store_legacy_scope. */ - char *zova_legacy_project; - /* The workspace's edges, read once from the graph; see store_zova_edges. */ - store_zova_edge_t *zova_edges; - int zova_edge_count; - bool zova_edges_loaded; - char **zova_edge_types; - int zova_edge_type_count; - int zova_native_topology_ops; - - /* Prepared statements (lazily initialized, cached for lifetime) */ - sqlite3_stmt *stmt_upsert_node; - sqlite3_stmt *stmt_find_node_by_id; - sqlite3_stmt *stmt_find_node_by_qn; - sqlite3_stmt *stmt_find_node_by_qn_any; /* QN lookup without project filter */ - sqlite3_stmt *stmt_find_nodes_by_name; - sqlite3_stmt *stmt_find_nodes_by_name_any; /* name lookup without project filter */ - sqlite3_stmt *stmt_find_nodes; - sqlite3_stmt *stmt_find_nodes_by_label; - sqlite3_stmt *stmt_find_nodes_by_file; - sqlite3_stmt *stmt_count_nodes; - sqlite3_stmt *stmt_delete_nodes_by_project; - sqlite3_stmt *stmt_delete_nodes_by_file; - sqlite3_stmt *stmt_delete_nodes_by_label; - - sqlite3_stmt *stmt_insert_edge; - sqlite3_stmt *stmt_find_edges_by_source; - sqlite3_stmt *stmt_find_edges_by_target; - sqlite3_stmt *stmt_find_edges_by_source_type; - sqlite3_stmt *stmt_find_edges_by_target_type; - sqlite3_stmt *stmt_find_edges_by_type; - sqlite3_stmt *stmt_count_edges; - sqlite3_stmt *stmt_count_edges_by_type; - sqlite3_stmt *stmt_delete_edges_by_project; - sqlite3_stmt *stmt_delete_edges_by_type; - - sqlite3_stmt *stmt_upsert_project; - sqlite3_stmt *stmt_get_project; - sqlite3_stmt *stmt_list_projects; - sqlite3_stmt *stmt_delete_project; - - sqlite3_stmt *stmt_upsert_file_hash; - sqlite3_stmt *stmt_get_file_hashes; - sqlite3_stmt *stmt_delete_file_hash; - sqlite3_stmt *stmt_delete_file_hashes; -}; - -static void store_close_zova_vector_session(cbm_store_t *s); -static void store_close_zova_graph_session(cbm_store_t *s); -static void store_clear_zova_graph_workspace(cbm_store_t *s); - -/* ── Helpers ────────────────────────────────────────────────────── */ - -static void store_set_error(cbm_store_t *s, const char *msg) { - snprintf(s->errbuf, sizeof(s->errbuf), "%s", msg); -} - -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)); -} - -static int exec_sql(cbm_store_t *s, const char *sql) { - if (!s || !s->db) { - return CBM_STORE_ERR; - } - char *err = NULL; - int rc = sqlite3_exec(s->db, sql, NULL, NULL, &err); - if (rc != SQLITE_OK) { - snprintf(s->errbuf, sizeof(s->errbuf), "exec: %s", err ? err : "unknown"); - sqlite3_free(err); - return CBM_STORE_ERR; - } - return CBM_STORE_OK; -} - -/* Safe string: returns "" if NULL. */ -static const char *safe_str(const char *s) { - return s ? s : ""; -} - -/* Safe properties: returns "{}" if NULL. */ -static const char *safe_props(const char *s) { - return (s && s[0]) ? s : "{}"; -} - -/* Prepare a statement (cached). If already prepared, reset+clear. */ -static sqlite3_stmt *prepare_cached(cbm_store_t *s, sqlite3_stmt **slot, const char *sql) { - if (!s || !s->db) { - return NULL; - } - if (*slot) { - sqlite3_reset(*slot); - sqlite3_clear_bindings(*slot); - return *slot; - } - int rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, slot, NULL); - if (rc != SQLITE_OK) { - store_set_error_sqlite(s, "prepare"); - return NULL; - } - return *slot; -} - -/* Get ISO-8601 timestamp. */ -static void iso_now(char *buf, size_t sz) { - time_t t = time(NULL); - struct tm tm; - cbm_gmtime_r(&t, &tm); - (void)strftime(buf, sz, "%Y-%m-%dT%H:%M:%SZ", - &tm); // cert-err33-c: strftime only fails if buffer is too small — 21-byte ISO - // timestamp always fits in caller-provided buffers -} - -/* ── Schema ─────────────────────────────────────────────────────── */ - -static int init_schema(cbm_store_t *s) { - const char *ddl = - "CREATE TABLE IF NOT EXISTS projects (" - " name TEXT PRIMARY KEY," - " indexed_at TEXT NOT NULL," - " root_path TEXT NOT NULL" - ");" - "CREATE TABLE IF NOT EXISTS file_hashes (" - " project TEXT NOT NULL REFERENCES projects(name) ON DELETE CASCADE," - " rel_path TEXT NOT NULL," - " sha256 TEXT NOT NULL," - " mtime_ns INTEGER NOT NULL DEFAULT 0," - " size INTEGER NOT NULL DEFAULT 0," - " PRIMARY KEY (project, rel_path)" - ");" - "CREATE TABLE IF NOT EXISTS nodes (" - " id INTEGER PRIMARY KEY AUTOINCREMENT," - " project TEXT NOT NULL REFERENCES projects(name) ON DELETE CASCADE," - " label TEXT NOT NULL," - " name TEXT NOT NULL," - " qualified_name TEXT NOT NULL," - " file_path TEXT DEFAULT ''," - " start_line INTEGER DEFAULT 0," - " end_line INTEGER DEFAULT 0," - " properties TEXT DEFAULT '{}'," - " UNIQUE(project, qualified_name)" - ");" - /* local_name_gen (#768): IMPORTS edges carry one imported symbol's - * local_name each, so uniqueness must discriminate on it — two named - * imports from the same specifier are distinct edges. Non-IMPORTS - * edges get '' (NOT NULL: NULLs never conflict in a UNIQUE index, - * which would break their dedup entirely). Mirrors the graph-buffer - * dedup key (make_edge_key) — keep both in sync. */ - "CREATE TABLE IF NOT EXISTS edges (" - " id INTEGER PRIMARY KEY AUTOINCREMENT," - " project TEXT NOT NULL REFERENCES projects(name) ON DELETE CASCADE," - " source_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE," - " target_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE," - " type TEXT NOT NULL," - " properties TEXT DEFAULT '{}'," - " url_path_gen TEXT GENERATED ALWAYS AS (json_extract(properties,'$.url_path'))," - " local_name_gen TEXT GENERATED ALWAYS AS (CASE WHEN type='IMPORTS'" - " THEN coalesce(json_extract(properties,'$.local_name'),'') ELSE '' END)," - " UNIQUE(source_id, target_id, type, local_name_gen)" - ");" - "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" - ");"; - - int rc = exec_sql(s, ddl); - if (rc != CBM_STORE_OK) { - return rc; - } - - /* Schema-compat probe (#768): DBs created before the local_name_gen - * discriminator still enforce UNIQUE(source_id,target_id,type) and lack - * the column — the widened upsert in cbm_store_insert_edge can neither - * prepare nor let two named imports coexist against them. SQLite cannot - * ALTER a table-level UNIQUE constraint in place, so fail the open: - * callers already treat an unopenable DB as incompatible (a full index - * deletes + rebuilds it, artifact import refuses and falls back to a - * reindex). Read-only query opens skip init_schema and keep working. */ - { - sqlite3_stmt *probe = NULL; - if (sqlite3_prepare_v2(s->db, "SELECT local_name_gen FROM edges LIMIT 0;", CBM_NOT_FOUND, - &probe, NULL) != SQLITE_OK) { - cbm_log_warn("store.schema", "result", "incompatible", "missing", - "edges.local_name_gen"); - return CBM_STORE_ERR; - } - sqlite3_finalize(probe); - } - - /* FTS5 contentless virtual table for BM25 full-text search. - * Contentless (content='') means FTS5 stores only the inverted index, - * not a copy of the source text — required for camelCase tokenization - * because we feed it `cbm_camel_split(name)` at insert time but want - * queries to match against the split tokens, not the original. - * Fails silently if FTS5 is not compiled in (SQLITE_ENABLE_FTS5). */ - { - char *fts_err = NULL; - int fts_rc = sqlite3_exec(s->db, - "CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(" - " name, qualified_name, label, file_path," - " content=''," - " tokenize='unicode61 remove_diacritics 2'" - ");", - NULL, NULL, &fts_err); - if (fts_rc != SQLITE_OK && fts_err) { - sqlite3_free(fts_err); - } - } - return CBM_STORE_OK; -} - -static int create_user_indexes(cbm_store_t *s) { - const char *sql = - "CREATE INDEX IF NOT EXISTS idx_nodes_label ON nodes(project, label);" - "CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(project, name);" - "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(project, file_path);" - "CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id, type);" - "CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id, type);" - "CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(project, type);" - "CREATE INDEX IF NOT EXISTS idx_edges_target_type ON edges(project, target_id, type);" - "CREATE INDEX IF NOT EXISTS idx_edges_source_type ON edges(project, source_id, type);" - "CREATE INDEX IF NOT EXISTS idx_edges_url_path ON edges(project, url_path_gen);"; - /* NOTE: a partial expression index on json_extract(properties,'$.is_entry_point') - * was tried for arch_entry_points and REVERTED: json_extract in an index WHERE - * aborts CREATE INDEX (and thus store open) on any row whose properties JSON is - * malformed — and pre-fix databases contain such rows (see - * pipeline_def_props_valid_json_when_oversized). Revisit only with a - * json_valid()-guarded expression once legacy DBs have aged out. */ - return exec_sql(s, sql); -} - -int64_t cbm_store_resolve_mmap_size(void) { - enum { MMAP_DEFAULT = 67108864, BASE_10 = 10 }; /* default 64 MB; decimal radix */ - char buf[ST_BUF_64]; - if (cbm_safe_getenv("CBM_SQLITE_MMAP_SIZE", buf, sizeof(buf), NULL) == NULL) { - return (int64_t)MMAP_DEFAULT; - } - char *end = NULL; - long long parsed = strtoll(buf, &end, BASE_10); - if (end == buf || *end != '\0') { - /* Malformed — fall back to default rather than fail the store open. */ - return (int64_t)MMAP_DEFAULT; - } - if (parsed < 0) { - return 0; - } - return (int64_t)parsed; -} - -/* Configure connection pragmas. - * in_memory — :memory: DB (synchronous OFF, no journal file). - * read_only — query-only connection opened SQLITE_OPEN_READONLY. Runs - * ONLY non-writing pragmas (foreign_keys, temp_store, - * busy_timeout, mmap_size) and SKIPS journal_mode=WAL, - * wal_checkpoint and synchronous — those WRITE to the DB/WAL - * and would (a) mutate the DB on every read query and (b) - * fail on a read-only DB file / filesystem. - * in_memory and read_only are mutually exclusive. */ -static int configure_pragmas(cbm_store_t *s, bool in_memory, bool read_only) { - int rc; - rc = exec_sql(s, "PRAGMA foreign_keys = ON;"); - if (rc != CBM_STORE_OK) { - return rc; - } - rc = exec_sql(s, "PRAGMA temp_store = MEMORY;"); - if (rc != CBM_STORE_OK) { - return rc; - } - - if (in_memory) { - rc = exec_sql(s, "PRAGMA synchronous = OFF;"); - } else if (read_only) { - /* Non-writing pragmas only — see the function comment. */ - rc = exec_sql(s, "PRAGMA busy_timeout = 10000;"); - if (rc != CBM_STORE_OK) { - return rc; - } - char mmap_sql[ST_BUF_64]; - snprintf(mmap_sql, sizeof(mmap_sql), "PRAGMA mmap_size = %lld;", - (long long)cbm_store_resolve_mmap_size()); - rc = exec_sql(s, mmap_sql); - } else { - rc = exec_sql(s, "PRAGMA busy_timeout = 10000;"); - if (rc != CBM_STORE_OK) { - return rc; - } - rc = exec_sql(s, "PRAGMA journal_mode = WAL;"); - if (rc != CBM_STORE_OK) { - return rc; - } - /* Recover stale WAL from previous crash (best-effort). - * PASSIVE never blocks readers and never ftruncates. - * May fail with SQLITE_BUSY if another process holds a lock. */ - (void)sqlite3_exec(s->db, "PRAGMA wal_checkpoint(PASSIVE)", NULL, NULL, NULL); - rc = exec_sql(s, "PRAGMA synchronous = NORMAL;"); - if (rc != CBM_STORE_OK) { - return rc; - } - char mmap_sql[ST_BUF_64]; - snprintf(mmap_sql, sizeof(mmap_sql), "PRAGMA mmap_size = %lld;", - (long long)cbm_store_resolve_mmap_size()); - rc = exec_sql(s, mmap_sql); - } - return rc; -} - -/* ── camelCase splitter for FTS5 indexing ───────────────────────── */ - -/* Emits the original identifier plus a space-separated split version, so FTS5's - * whitespace tokenizer produces both `updateCloudClient` (exact match) and the - * word tokens `update`, `cloud`, `client`. Rules: - * 1. insert space before an uppercase letter preceded by a lowercase letter - * ("updateCloud" → "update Cloud") - * 2. insert space before an uppercase letter preceded by another uppercase - * but followed by a lowercase letter ("XMLParser" → "XML Parser", not - * "X M L Parser") - * snake_case is already split by FTS5's unicode61 tokenizer on `_`. */ -enum { - CAMEL_SPLIT_BUF = 2048, - SQLITE_AUTO_LEN = -1, /* sqlite3_result_text sentinel: use strlen(). */ - CAMEL_BUF_GUARD = 2, /* reserve room for inserted space + NUL terminator. */ -}; - -/* Denominator epsilon guard for double-precision cosine. */ -#define CBM_STORE_DENOM_EPS_D 1e-10 -#define CBM_STORE_DENOM_EPS_F 1e-10F -#define CBM_STORE_INT8_MAX 127.0F -#define CBM_STORE_UNIT_POS_F 1.0F -#define CBM_STORE_UNIT_POS_D 1.0 - -/* Module-local copy of SQLite's SQLITE_TRANSIENT sentinel ((void*)-1). - * We construct it via memcpy from a volatile intptr_t sentinel so the - * resulting expression isn't syntactically a direct int-to-ptr cast — - * clang-tidy's performance-no-int-to-ptr sees the memcpy boundary and - * doesn't flag it per-use-site. */ -static sqlite3_destructor_type cbm_sqlite_transient_destructor(void) { - static const volatile intptr_t raw = -1; - sqlite3_destructor_type dtor = NULL; - memcpy(&dtor, (const void *)&raw, sizeof(dtor)); - return dtor; -} -#define CBM_SQLITE_TRANSIENT (cbm_sqlite_transient_destructor()) - -/* True if we should insert a space BEFORE input[i] to split a camelCase word - * boundary (lowercase→uppercase or uppercase-run→uppercase-then-lowercase). */ -static bool camel_should_split(const char *input, int i) { - if (i <= 0) { - return false; - } - char curr = input[i]; - char prev = input[i - SKIP_ONE]; - char next = input[i + SKIP_ONE]; - if (curr >= 'A' && curr <= 'Z' && prev >= 'a' && prev <= 'z') { - return true; - } - if (curr >= 'A' && curr <= 'Z' && prev >= 'A' && prev <= 'Z' && next >= 'a' && next <= 'z') { - return true; - } - return false; -} - -static void sqlite_camel_split(sqlite3_context *ctx, int argc, sqlite3_value **argv) { - (void)argc; - const char *input = (const char *)sqlite3_value_text(argv[0]); - if (!input || !input[0]) { - sqlite3_result_text(ctx, input ? input : "", SQLITE_AUTO_LEN, CBM_SQLITE_TRANSIENT); - return; - } - char buf[CAMEL_SPLIT_BUF]; - int len = snprintf(buf, sizeof(buf), "%s ", input); - if (len < 0 || len >= (int)sizeof(buf)) { - /* Input too long — fall back to the original string unmodified. */ - sqlite3_result_text(ctx, input, SQLITE_AUTO_LEN, CBM_SQLITE_TRANSIENT); - return; - } - for (int i = 0; input[i] && len < (int)sizeof(buf) - CAMEL_BUF_GUARD; i++) { - if (camel_should_split(input, i)) { - buf[len++] = ' '; - } - buf[len++] = input[i]; - } - buf[len] = '\0'; - sqlite3_result_text(ctx, buf, len, CBM_SQLITE_TRANSIENT); -} - -/* ── REGEXP function for SQLite ──────────────────────────────────── */ - -/* Destructor passed to sqlite3_set_auxdata — frees the cached compiled regex. */ -static void regex_free_cb(void *p) { - cbm_regex_t *re = (cbm_regex_t *)p; - cbm_regfree(re); - free(re); -} - -/* Cache the compiled regex on argument slot 0 for the lifetime of the statement. - * sqlite3_get_auxdata returns the cached pointer on subsequent rows (same parameter - * value), so cbm_regcomp is called exactly once per statement instead of once per row. */ -static void sqlite_regexp(sqlite3_context *ctx, int argc, sqlite3_value **argv) { - (void)argc; - const char *pattern = (const char *)sqlite3_value_text(argv[0]); - const char *text = (const char *)sqlite3_value_text(argv[SKIP_ONE]); - if (!pattern || !text) { - sqlite3_result_int(ctx, 0); - return; - } - - cbm_regex_t *re = (cbm_regex_t *)sqlite3_get_auxdata(ctx, 0); - if (!re) { - re = malloc(sizeof(cbm_regex_t)); - if (!re) { - sqlite3_result_error_nomem(ctx); - return; - } - if (cbm_regcomp(re, pattern, CBM_REG_EXTENDED | CBM_REG_NOSUB) != 0) { - free(re); - sqlite3_result_error(ctx, "invalid regex", CBM_NOT_FOUND); - return; - } - sqlite3_set_auxdata(ctx, 0, re, regex_free_cb); - } - - sqlite3_result_int(ctx, cbm_regexec(re, text, 0, NULL, 0) == 0 ? SKIP_ONE : 0); -} - -/* Case-insensitive REGEXP variant — same auxdata caching strategy. */ -static void sqlite_iregexp(sqlite3_context *ctx, int argc, sqlite3_value **argv) { - (void)argc; - const char *pattern = (const char *)sqlite3_value_text(argv[0]); - const char *text = (const char *)sqlite3_value_text(argv[SKIP_ONE]); - if (!pattern || !text) { - sqlite3_result_int(ctx, 0); - return; - } - - cbm_regex_t *re = (cbm_regex_t *)sqlite3_get_auxdata(ctx, 0); - if (!re) { - re = malloc(sizeof(cbm_regex_t)); - if (!re) { - sqlite3_result_error_nomem(ctx); - return; - } - if (cbm_regcomp(re, pattern, CBM_REG_EXTENDED | CBM_REG_NOSUB | CBM_REG_ICASE) != 0) { - free(re); - sqlite3_result_error(ctx, "invalid regex", CBM_NOT_FOUND); - return; - } - sqlite3_set_auxdata(ctx, 0, re, regex_free_cb); - } - - sqlite3_result_int(ctx, cbm_regexec(re, text, 0, NULL, 0) == 0 ? SKIP_ONE : 0); -} - -/* Cosine similarity between two int8 BLOB vectors. - * Returns float in [-1, 1]. Used for vector search at query time. */ -static void sqlite_cosine_i8(sqlite3_context *ctx, int argc, sqlite3_value **argv) { - (void)argc; - if (sqlite3_value_type(argv[0]) != SQLITE_BLOB || - sqlite3_value_type(argv[SKIP_ONE]) != SQLITE_BLOB) { - sqlite3_result_double(ctx, 0.0); - return; - } - int len_a = sqlite3_value_bytes(argv[0]); - int len_b = sqlite3_value_bytes(argv[SKIP_ONE]); - if (len_a != len_b || len_a == 0) { - sqlite3_result_double(ctx, 0.0); - return; - } - const int8_t *a = (const int8_t *)sqlite3_value_blob(argv[0]); - const int8_t *b = (const int8_t *)sqlite3_value_blob(argv[SKIP_ONE]); - int32_t dot = 0; - int32_t mag_a = 0; - int32_t mag_b = 0; - for (int i = 0; i < len_a; i++) { - dot += (int32_t)a[i] * (int32_t)b[i]; - mag_a += (int32_t)a[i] * (int32_t)a[i]; - mag_b += (int32_t)b[i] * (int32_t)b[i]; - } - double denom = sqrt((double)mag_a) * sqrt((double)mag_b); - sqlite3_result_double(ctx, denom > CBM_STORE_DENOM_EPS_D ? (double)dot / denom : 0.0); -} - -/* ── Lifecycle ──────────────────────────────────────────────────── */ - -/* SQLite authorizer: deny dangerous operations that could be exploited via - * SQL injection through the Cypher→SQL translation layer. */ -static int store_authorizer(void *user_data, int action, const char *p3, const char *p4, - const char *p5, const char *p6) { - (void)user_data; - (void)p3; - (void)p4; - (void)p5; - (void)p6; - switch (action) { - case SQLITE_ATTACH: /* ATTACH DATABASE — could create/read arbitrary files */ - case SQLITE_DETACH: /* DETACH DATABASE */ - return SQLITE_DENY; - default: - return SQLITE_OK; - } -} - -static cbm_store_t *store_open_internal(const char *path, bool in_memory) { - cbm_store_t *s = calloc(CBM_ALLOC_ONE, sizeof(cbm_store_t)); - if (!s) { - return NULL; - } - - int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; - if (in_memory) { - flags |= SQLITE_OPEN_MEMORY; - } - - int rc = sqlite3_open_v2(path, &s->db, flags, NULL); - if (rc != SQLITE_OK) { - free(s); - return NULL; - } - - if (path && !in_memory) { - s->db_path = cbm_heap_strdup(path); - } - - /* Security: block ATTACH/DETACH to prevent file creation via SQL injection. - * The authorizer runs inside SQLite's query planner — no string-level bypass. */ - sqlite3_set_authorizer(s->db, store_authorizer, NULL); - - /* Register REGEXP function (SQLite doesn't have one built-in) */ - sqlite3_create_function(s->db, "regexp", ST_COL_2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL, - sqlite_regexp, NULL, NULL); - /* Case-insensitive variant for search with case_sensitive=false */ - sqlite3_create_function(s->db, "iregexp", ST_COL_2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL, - sqlite_iregexp, NULL, NULL); - /* Int8 cosine similarity for vector search */ - sqlite3_create_function(s->db, "cbm_cosine_i8", ST_COL_2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, - NULL, sqlite_cosine_i8, NULL, NULL); - /* camelCase splitter for FTS5 BM25 indexing */ - sqlite3_create_function(s->db, "cbm_camel_split", SKIP_ONE, SQLITE_UTF8 | SQLITE_DETERMINISTIC, - NULL, sqlite_camel_split, NULL, NULL); - - if (configure_pragmas(s, in_memory, false) != CBM_STORE_OK || init_schema(s) != CBM_STORE_OK || - create_user_indexes(s) != CBM_STORE_OK) { - sqlite3_close(s->db); - safe_str_free(&s->db_path); - free(s); - return NULL; - } - - return s; -} - -cbm_store_t *cbm_store_open_memory(void) { - return store_open_internal(":memory:", true); -} - -static bool store_cleanup_pending(const char *db_path) { - char marker[2048]; - int n = snprintf(marker, sizeof(marker), "%s.cbm-cleanup-pending", db_path ? db_path : ""); - if (n <= 0 || (size_t)n >= sizeof(marker)) return false; - struct stat marker_stat; - return cbm_lstat(marker, &marker_stat) == 0; -} - -const char *cbm_store_db_path(const cbm_store_t *s) { - return s ? s->db_path : NULL; -} - -/* Build a SQLite "file:" URI with immutable=1 from a filesystem path. - * immutable=1 bypasses WAL and locking and reads the main DB file directly — - * used only as a fallback for read-only filesystems where the wal-index - * (-shm) cannot be created. URI-special characters are percent-encoded. - * Windows backslashes are normalized to '/', and a leading '/' is inserted - * for drive-letter paths so the drive is not parsed as a URI authority. - * Returns false if the output buffer is too small. */ -static bool build_immutable_uri(const char *path, char *out, size_t out_sz) { - static const char PREFIX[] = "file://"; - static const char SUFFIX[] = "?immutable=1"; - static const char HEX[] = "0123456789ABCDEF"; - size_t prefix_len = sizeof(PREFIX) - 1; - size_t suffix_len = sizeof(SUFFIX) - 1; - if (prefix_len + 1 > out_sz) { - return false; - } - memcpy(out, PREFIX, prefix_len); - size_t pos = prefix_len; - - /* Ensure the path component begins with '/' (POSIX absolute paths already - * do; Windows "C:\..." gets a leading '/' -> "/C:/..."). */ - if (path[0] != '/') { - if (pos + 1 >= out_sz) { - return false; - } - out[pos++] = '/'; - } - - for (const unsigned char *p = (const unsigned char *)path; *p != '\0'; p++) { - unsigned char c = *p; - if (c == '\\') { - c = '/'; /* normalize Windows separators */ - } - bool safe = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || - c == '/' || c == '.' || c == '-' || c == '_' || c == '~' || c == ':'; - if (safe) { - if (pos + 1 >= out_sz) { - return false; - } - out[pos++] = (char)c; - } else { - if (pos + 3 >= out_sz) { - return false; - } - out[pos++] = '%'; - out[pos++] = HEX[(c >> 4) & 0xF]; - out[pos++] = HEX[c & 0xF]; - } - } - - if (pos + suffix_len + 1 > out_sz) { - return false; - } - memcpy(out + pos, SUFFIX, suffix_len); - pos += suffix_len; - out[pos] = '\0'; - return true; -} - -cbm_store_t *cbm_store_open_path_query(const char *db_path) { - if (!db_path || store_cleanup_pending(db_path)) { - return NULL; - } - - cbm_store_t *s = calloc(CBM_ALLOC_ONE, sizeof(cbm_store_t)); - if (!s) { - return NULL; - } - - /* Query tools open the project DB READ-ONLY: a read query must never - * mutate the DB (the previous READWRITE open + WAL write-pragmas did), - * and must work on a read-only DB file / filesystem. - * - * Try a plain READONLY open first — on a normal writable filesystem this - * reads WAL frames correctly via the -shm wal-index. SQLite opens lazily, - * so a read-only-filesystem failure (cannot create -shm for a WAL-mode - * DB) surfaces on first access, not at open time; we probe with a trivial - * read to force it. If the probe fails, retry once with an immutable URI - * that bypasses WAL and reads the main DB file directly. - * - * No SQLITE_OPEN_CREATE on either path — a missing DB must return NULL - * (no ghost .db for unknown/unindexed projects). */ - int rc = sqlite3_open_v2(db_path, &s->db, SQLITE_OPEN_READONLY, NULL); - if (rc == SQLITE_OK) { - /* Wait out a concurrent writer before probing: a busy probe is not a - * read-only filesystem, and the immutable fallback below ignores the - * WAL, so it would serve a stale snapshot without recent commits. */ - sqlite3_busy_timeout(s->db, ST_BUSY_TIMEOUT_MS); - /* Force first DB access so a read-only-FS WAL failure surfaces now. */ - if (sqlite3_exec(s->db, "SELECT 1 FROM sqlite_master LIMIT 1;", NULL, NULL, NULL) != - SQLITE_OK) { - bool busy = (sqlite3_errcode(s->db) & 0xFF) == SQLITE_BUSY; - sqlite3_close(s->db); - s->db = NULL; - if (busy) { - free(s); - return NULL; - } - rc = SQLITE_CANTOPEN; /* trigger immutable fallback */ - } - } - if (rc != SQLITE_OK) { - sqlite3_close(s->db); /* no-op if already NULL */ - s->db = NULL; - /* A genuinely missing DB must return NULL without creating anything — - * only retry with the immutable URI when the file exists but could not - * be opened (the read-only-filesystem case). This also keeps the - * common "project not found" path to a single open attempt. */ - if (!cbm_file_exists(db_path)) { - free(s); - return NULL; - } - char uri[ST_QUERY_URI_MAX]; - if (!build_immutable_uri(db_path, uri, sizeof(uri))) { - free(s); - return NULL; - } - rc = sqlite3_open_v2(uri, &s->db, SQLITE_OPEN_READONLY | SQLITE_OPEN_URI, NULL); - if (rc != SQLITE_OK) { - /* sqlite3_open_v2 allocates a handle even on failure — must close it. */ - sqlite3_close(s->db); - free(s); - return NULL; - } - } - - s->db_path = cbm_heap_strdup(db_path); - - /* Security: block ATTACH/DETACH to prevent file creation via SQL injection. */ - sqlite3_set_authorizer(s->db, store_authorizer, NULL); - - /* Register REGEXP functions. */ - sqlite3_create_function(s->db, "regexp", ST_COL_2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL, - sqlite_regexp, NULL, NULL); - sqlite3_create_function(s->db, "iregexp", ST_COL_2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL, - sqlite_iregexp, NULL, NULL); - sqlite3_create_function(s->db, "cbm_cosine_i8", ST_COL_2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, - NULL, sqlite_cosine_i8, NULL, NULL); - sqlite3_create_function(s->db, "cbm_camel_split", SKIP_ONE, SQLITE_UTF8 | SQLITE_DETERMINISTIC, - NULL, sqlite_camel_split, NULL, NULL); - - if (configure_pragmas(s, false, true) != CBM_STORE_OK) { - sqlite3_close(s->db); - safe_str_free(&s->db_path); - free(s); - return NULL; - } - - return s; -} - -static int64_t store_stable_numeric_id(const char *value) { - uint64_t hash = 1469598103934665603ULL; - for (const unsigned char *p = (const unsigned char *)value; p && *p; ++p) - hash = (hash ^ *p) * 1099511628211ULL; - return (int64_t)(hash & INT64_MAX); -} - -cbm_store_t *cbm_store_open_zova_workspace_query(const char *db_path, - const char *workspace_id) { - if (!db_path || cbm_zova_workspace_id_validate(workspace_id) != 0) return NULL; - cbm_store_t *s = cbm_store_open_path_query(db_path); - if (!s) return NULL; - /* A user-local database can hold several workspaces, and graph hydration - * precedes vector sampling on the same handle. A bounded 256 MiB ceiling - * keeps both working sets resident on large repositories without changing - * compatibility project-store behavior. SQLite allocates pages lazily. */ - if (sqlite3_exec(s->db, "PRAGMA cache_size=-262144", NULL, NULL, NULL) != SQLITE_OK) { - cbm_store_close(s); - return NULL; - } - sqlite3_stmt *project_stmt = NULL; - char project[256] = {0}; - if (sqlite3_prepare_v2( - s->db, - "SELECT p.project FROM cbm_projects_v1 p JOIN cbm_workspace_registry r " - "USING(workspace_key) WHERE r.workspace_id=?1", - -1, &project_stmt, NULL) != SQLITE_OK || - sqlite3_bind_text(project_stmt, 1, workspace_id, -1, SQLITE_STATIC) != SQLITE_OK || - sqlite3_step(project_stmt) != SQLITE_ROW) { - if (project_stmt) sqlite3_finalize(project_stmt); - cbm_store_close(s); - return NULL; - } - const char *project_value = (const char *)sqlite3_column_text(project_stmt, 0); - if (project_value) snprintf(project, sizeof(project), "%s", project_value); - sqlite3_finalize(project_stmt); - if (!project[0]) { - cbm_store_close(s); - return NULL; - } - s->zova_repository = cbm_zova_repository_open(db_path, project); - if (!s->zova_repository || - strcmp(cbm_zova_repository_workspace_id(s->zova_repository), workspace_id) != 0) { - cbm_store_close(s); - return NULL; - } - s->zova_graph_session = cbm_zova_graph_session_open(db_path); - if (!s->zova_graph_session) { - cbm_store_close(s); - return NULL; - } - snprintf(s->zova_graph_workspace_id, sizeof(s->zova_graph_workspace_id), "%s", workspace_id); - s->zova_workspace_query = true; - return s; -} - -int cbm_store_zova_native_topology_ops(const cbm_store_t *s) { - return s ? s->zova_native_topology_ops : 0; -} - -/* ── Integrity check ───────────────────────────────────────────── */ - -static void finalize_stmt(sqlite3_stmt **s) { - if (*s) { - sqlite3_finalize(*s); - *s = NULL; - } -} - -cbm_store_t *cbm_store_open_backend(const cbm_store_backend_t *backend, void *ctx) { - if (!backend) { - return NULL; - } - cbm_store_t *s = calloc(1, sizeof(*s)); - if (s) { - s->backend = backend; - s->backend_ctx = ctx; - } - return s; -} - -void cbm_store_close(cbm_store_t *s) { - if (!s) { - return; - } - if (s->backend) { - free(s); - return; - } - - store_close_zova_vector_session(s); - store_close_zova_graph_session(s); - store_clear_zova_graph_workspace(s); - cbm_zova_repository_close(s->zova_repository); - s->zova_repository = NULL; - free(s->zova_legacy_project); - s->zova_legacy_project = NULL; - free(s->zova_edges); - for (int i = 0; i < s->zova_edge_type_count; i++) { - free(s->zova_edge_types[i]); - } - free(s->zova_edge_types); - - /* Checkpoint WAL before close to prevent orphan WAL accumulation. - * Best-effort — silently skips if concurrent reader holds a lock. */ - if (s->db && s->db_path) { - (void)sqlite3_wal_checkpoint_v2(s->db, NULL, SQLITE_CHECKPOINT_PASSIVE, NULL, NULL); - } - - /* Finalize all cached statements */ - finalize_stmt(&s->stmt_upsert_node); - finalize_stmt(&s->stmt_find_node_by_id); - finalize_stmt(&s->stmt_find_node_by_qn); - finalize_stmt(&s->stmt_find_node_by_qn_any); - finalize_stmt(&s->stmt_find_nodes_by_name); - finalize_stmt(&s->stmt_find_nodes_by_name_any); - finalize_stmt(&s->stmt_find_nodes); - finalize_stmt(&s->stmt_find_nodes_by_label); - finalize_stmt(&s->stmt_find_nodes_by_file); - finalize_stmt(&s->stmt_count_nodes); - finalize_stmt(&s->stmt_delete_nodes_by_project); - finalize_stmt(&s->stmt_delete_nodes_by_file); - finalize_stmt(&s->stmt_delete_nodes_by_label); - - finalize_stmt(&s->stmt_insert_edge); - finalize_stmt(&s->stmt_find_edges_by_source); - finalize_stmt(&s->stmt_find_edges_by_target); - finalize_stmt(&s->stmt_find_edges_by_source_type); - finalize_stmt(&s->stmt_find_edges_by_target_type); - finalize_stmt(&s->stmt_find_edges_by_type); - finalize_stmt(&s->stmt_count_edges); - finalize_stmt(&s->stmt_count_edges_by_type); - finalize_stmt(&s->stmt_delete_edges_by_project); - finalize_stmt(&s->stmt_delete_edges_by_type); - - finalize_stmt(&s->stmt_upsert_project); - finalize_stmt(&s->stmt_get_project); - finalize_stmt(&s->stmt_list_projects); - finalize_stmt(&s->stmt_delete_project); - - finalize_stmt(&s->stmt_upsert_file_hash); - finalize_stmt(&s->stmt_get_file_hashes); - finalize_stmt(&s->stmt_delete_file_hash); - finalize_stmt(&s->stmt_delete_file_hashes); - - /* Use sqlite3_close_v2 — auto-deallocates when last statement finalizes. - * Prevents ASan false-positive leaks from sqlite3 internal state. */ - sqlite3_close_v2(s->db); - safe_str_free(&s->db_path); - free(s); -} - -sqlite3 *cbm_store_get_db(cbm_store_t *s) { - return s ? s->db : NULL; -} - -int cbm_store_exec(cbm_store_t *s, const char *sql) { - return exec_sql(s, sql); -} - -const char *cbm_store_error(cbm_store_t *s) { - return s ? s->errbuf : "null store"; -} - -/* ── Transaction ────────────────────────────────────────────────── */ - -int cbm_store_begin(cbm_store_t *s) { - return exec_sql(s, "BEGIN IMMEDIATE;"); -} - -int cbm_store_commit(cbm_store_t *s) { - return exec_sql(s, "COMMIT;"); -} - -int cbm_store_rollback(cbm_store_t *s) { - return exec_sql(s, "ROLLBACK;"); -} - -/* ── Bulk write ─────────────────────────────────────────────────── */ - -int cbm_store_begin_bulk(cbm_store_t *s) { - /* Stay in WAL mode throughout. Switching to MEMORY journal mode would - * make the database unrecoverable if the process crashes mid-write, - * because the in-memory rollback journal is lost on crash. - * WAL mode is crash-safe: uncommitted WAL entries are simply discarded - * on the next open. Performance is preserved via synchronous=OFF and a - * larger cache, which are safe with WAL. */ - int rc = exec_sql(s, "PRAGMA synchronous = OFF;"); - if (rc != CBM_STORE_OK) { - return rc; - } - return exec_sql(s, "PRAGMA cache_size = -65536;"); /* CBM_SZ_64 MB */ -} - -int cbm_store_end_bulk(cbm_store_t *s) { - int rc = exec_sql(s, "PRAGMA synchronous = NORMAL;"); - if (rc != CBM_STORE_OK) { - return rc; - } - return exec_sql(s, "PRAGMA cache_size = -2000;"); /* default ~2 MB */ -} - -int cbm_store_drop_indexes(cbm_store_t *s) { - return exec_sql(s, "DROP INDEX IF EXISTS idx_nodes_label;" - "DROP INDEX IF EXISTS idx_nodes_name;" - "DROP INDEX IF EXISTS idx_nodes_file;" - "DROP INDEX IF EXISTS idx_edges_source;" - "DROP INDEX IF EXISTS idx_edges_target;" - "DROP INDEX IF EXISTS idx_edges_type;" - "DROP INDEX IF EXISTS idx_edges_target_type;" - "DROP INDEX IF EXISTS idx_edges_source_type;"); -} - -int cbm_store_create_indexes(cbm_store_t *s) { - return create_user_indexes(s); -} - -/* ── Checkpoint ─────────────────────────────────────────────────── */ - -/* ── Dump ───────────────────────────────────────────────────────── */ - -/* ── Project CRUD ───────────────────────────────────────────────── */ - -int cbm_store_upsert_project(cbm_store_t *s, const char *name, const char *root_path) { - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_upsert_project, - "INSERT INTO projects (name, indexed_at, root_path) VALUES (?1, ?2, ?3) " - "ON CONFLICT(name) DO UPDATE SET indexed_at=?2, root_path=?3;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - char ts[CBM_SZ_64]; - iso_now(ts, sizeof(ts)); - - bind_text(stmt, SKIP_ONE, name); - bind_text(stmt, ST_COL_2, ts); - bind_text(stmt, ST_COL_3, root_path); - - int rc = sqlite3_step(stmt); - if (rc != SQLITE_DONE) { - store_set_error_sqlite(s, "upsert_project"); - return CBM_STORE_ERR; - } - return CBM_STORE_OK; -} - -int cbm_store_get_project(cbm_store_t *s, const char *name, cbm_project_t *out) { - if (s->zova_workspace_query && s->zova_repository) - return cbm_zova_repository_get_project( - s->zova_repository, s->zova_graph_workspace_id, out); - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_get_project, - "SELECT name, indexed_at, root_path FROM projects WHERE name = ?1;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, name); - int rc = sqlite3_step(stmt); - if (rc == SQLITE_ROW) { - out->name = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, 0)); - out->indexed_at = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE)); - out->root_path = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_2)); - return CBM_STORE_OK; - } - return CBM_STORE_NOT_FOUND; -} - -int cbm_store_list_projects(cbm_store_t *s, cbm_project_t **out, int *count) { - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_list_projects, - "SELECT name, indexed_at, root_path FROM projects ORDER BY name;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - /* Collect into dynamic array */ - int cap = ST_INIT_CAP_8; - int n = 0; - cbm_project_t *arr = malloc(cap * sizeof(cbm_project_t)); - - int scan_rc1; - while ((scan_rc1 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - arr = safe_realloc(arr, cap * sizeof(cbm_project_t)); - } - arr[n].name = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, 0)); - arr[n].indexed_at = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE)); - arr[n].root_path = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_2)); - n++; - } - if (scan_rc1 != SQLITE_DONE) { /* SCANCHK:1:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - cbm_store_free_projects(arr, n); - *out = NULL; - *count = 0; - return CBM_STORE_ERR; - } - - *out = arr; - *count = n; - return CBM_STORE_OK; -} - -int cbm_store_delete_project(cbm_store_t *s, const char *name) { - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_delete_project, "DELETE FROM projects WHERE name = ?1;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, name); - int rc = sqlite3_step(stmt); - if (rc != SQLITE_DONE) { - store_set_error_sqlite(s, "delete_project"); - return CBM_STORE_ERR; - } - return CBM_STORE_OK; -} - -/* ── Node CRUD ──────────────────────────────────────────────────── */ - -int64_t cbm_store_upsert_node(cbm_store_t *s, const cbm_node_t *n) { - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_upsert_node, - "INSERT INTO nodes (project, label, name, qualified_name, file_path, " - "start_line, end_line, properties) " - "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) " - "ON CONFLICT(project, qualified_name) DO UPDATE SET " - "label=?2, name=?3, file_path=?5, start_line=?6, end_line=?7, properties=?8 " - "RETURNING id;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, safe_str(n->project)); - bind_text(stmt, ST_COL_2, safe_str(n->label)); - bind_text(stmt, ST_COL_3, safe_str(n->name)); - bind_text(stmt, ST_COL_4, safe_str(n->qualified_name)); - bind_text(stmt, ST_COL_5, safe_str(n->file_path)); - sqlite3_bind_int(stmt, ST_COL_6, n->start_line); - sqlite3_bind_int(stmt, ST_COL_7, n->end_line); - bind_text(stmt, ST_COL_8, safe_props(n->properties_json)); - - int rc = sqlite3_step(stmt); - if (rc == SQLITE_ROW) { - int64_t id = sqlite3_column_int64(stmt, 0); - sqlite3_reset(stmt); /* unblock COMMIT — RETURNING leaves stmt active */ - return id; - } - sqlite3_reset(stmt); - store_set_error_sqlite(s, "upsert_node"); - return CBM_STORE_ERR; -} - -/* Scan a node from current row of stmt. Heap-allocates strings. */ -static void scan_node(sqlite3_stmt *stmt, cbm_node_t *n) { - n->id = sqlite3_column_int64(stmt, 0); - n->project = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE)); - n->label = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_2)); - n->name = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_3)); - n->qualified_name = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_4)); - n->file_path = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_5)); - n->start_line = sqlite3_column_int(stmt, CBM_SZ_6); - n->end_line = sqlite3_column_int(stmt, CBM_SZ_7); - n->properties_json = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, ST_COL_8)); -} - -int cbm_store_find_node_by_id(cbm_store_t *s, int64_t id, cbm_node_t *out) { - if (s && s->backend) return s->backend->find_node_by_id(s->backend_ctx, id, out); - if (s && s->zova_workspace_query && s->zova_repository) - return cbm_zova_repository_find_node_by_numeric_id( - s->zova_repository, s->zova_graph_workspace_id, id, out); - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_find_node_by_id, - "SELECT id, project, label, name, qualified_name, file_path, " - "start_line, end_line, properties FROM nodes WHERE id = ?1;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - sqlite3_bind_int64(stmt, SKIP_ONE, id); - int rc = sqlite3_step(stmt); - if (rc == SQLITE_ROW) { - scan_node(stmt, out); - return CBM_STORE_OK; - } - return CBM_STORE_NOT_FOUND; -} - -int cbm_store_find_node_by_qn(cbm_store_t *s, const char *project, const char *qn, - cbm_node_t *out) { - if (!s || !s->db) { - return CBM_STORE_ERR; - } - if (s->zova_workspace_query && s->zova_repository) - return cbm_zova_repository_find_node_by_qn( - s->zova_repository, s->zova_graph_workspace_id, qn, out); - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_find_node_by_qn, - "SELECT id, project, label, name, qualified_name, file_path, " - "start_line, end_line, properties FROM nodes " - "WHERE project = ?1 AND qualified_name = ?2;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - bind_text(stmt, ST_COL_2, qn); - int rc = sqlite3_step(stmt); - if (rc == SQLITE_ROW) { - scan_node(stmt, out); - return CBM_STORE_OK; - } - return CBM_STORE_NOT_FOUND; -} - -int cbm_store_find_node_by_qn_any(cbm_store_t *s, const char *qn, cbm_node_t *out) { - if (!s || !s->db) { - return CBM_STORE_ERR; - } - if (s->zova_workspace_query && s->zova_repository) - return cbm_zova_repository_find_node_by_qn( - s->zova_repository, s->zova_graph_workspace_id, qn, out); - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_find_node_by_qn_any, - "SELECT id, project, label, name, qualified_name, file_path, " - "start_line, end_line, properties FROM nodes " - "WHERE qualified_name = ?1 LIMIT 1;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, qn); - int rc = sqlite3_step(stmt); - if (rc == SQLITE_ROW) { - scan_node(stmt, out); - return CBM_STORE_OK; - } - return CBM_STORE_NOT_FOUND; -} - -int cbm_store_find_nodes_by_name_any(cbm_store_t *s, const char *name, cbm_node_t **out, - int *count) { - if (!s || !s->db) { - *out = NULL; - *count = 0; - return CBM_STORE_ERR; - } - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_find_nodes_by_name_any, - "SELECT id, project, label, name, qualified_name, file_path, " - "start_line, end_line, properties FROM nodes " - "WHERE name = ?1;"); - if (!stmt) { - *out = NULL; - *count = 0; - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, name); - - int cap = ST_INIT_CAP_16; - int n = 0; - cbm_node_t *arr = malloc(cap * sizeof(cbm_node_t)); - int scan_rc2; - while ((scan_rc2 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - arr = safe_realloc(arr, cap * sizeof(cbm_node_t)); - } - scan_node(stmt, &arr[n]); - n++; - } - if (scan_rc2 != SQLITE_DONE) { /* SCANCHK:2:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - cbm_store_free_nodes(arr, n); - *out = NULL; - *count = 0; - return CBM_STORE_ERR; - } - *out = arr; - *count = n; - return CBM_STORE_OK; -} - -int cbm_store_find_node_ids_by_qns(cbm_store_t *s, const char *project, const char **qns, - int qn_count, int64_t *out_ids) { - if (!s || !project || !qns || !out_ids || qn_count <= 0) { - return 0; - } - - /* Zero out results */ - memset(out_ids, 0, (size_t)qn_count * sizeof(int64_t)); - - int found = 0; - cbm_node_t node = {0}; - for (int i = 0; i < qn_count; i++) { - if (!qns[i]) { - continue; - } - int rc = cbm_store_find_node_by_qn(s, project, qns[i], &node); - if (rc == CBM_STORE_OK) { - out_ids[i] = node.id; - found++; - cbm_node_free_fields(&node); - memset(&node, 0, sizeof(node)); - } - } - return found; -} - -/* Generic: find multiple nodes by a single-column filter. */ -static int find_nodes_generic(cbm_store_t *s, sqlite3_stmt **slot, const char *sql, - const char *project, const char *val, cbm_node_t **out, int *count) { - if (!s || !s->db) { - *out = NULL; - *count = 0; - return CBM_STORE_ERR; - } - sqlite3_stmt *stmt = prepare_cached(s, slot, sql); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - if (val) { - bind_text(stmt, ST_COL_2, val); - } - - int cap = ST_INIT_CAP_16; - int n = 0; - cbm_node_t *arr = malloc(cap * sizeof(cbm_node_t)); - - int scan_rc3; - while ((scan_rc3 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - arr = safe_realloc(arr, cap * sizeof(cbm_node_t)); - } - scan_node(stmt, &arr[n]); - n++; - } - if (scan_rc3 != SQLITE_DONE) { /* SCANCHK:3:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - cbm_store_free_nodes(arr, n); - *out = NULL; - *count = 0; - return CBM_STORE_ERR; - } - - *out = arr; - *count = n; - return CBM_STORE_OK; -} - -static int node_public_order_compare(const void *left_ptr, const void *right_ptr) { - const cbm_node_t *left = left_ptr; - const cbm_node_t *right = right_ptr; - int cmp = strcmp(left->qualified_name ? left->qualified_name : "", - right->qualified_name ? right->qualified_name : ""); - if (cmp == 0) - cmp = strcmp(left->file_path ? left->file_path : "", - right->file_path ? right->file_path : ""); - if (cmp == 0) - cmp = (left->start_line > right->start_line) - - (left->start_line < right->start_line); - if (cmp == 0) - cmp = (left->end_line > right->end_line) - - (left->end_line < right->end_line); - if (cmp == 0) cmp = (left->id > right->id) - (left->id < right->id); - return cmp; -} - -int cbm_store_find_nodes_by_name(cbm_store_t *s, const char *project, const char *name, - cbm_node_t **out, int *count) { - if (s && (s->backend || (s->zova_workspace_query && s->zova_repository))) { - cbm_search_output_t search = {0}; - cbm_search_params_t params = { - .project = project, .name_pattern = name, .min_degree = -1, .max_degree = -1, - .limit = INT_MAX}; - int rc = s->backend - ? s->backend->search(s->backend_ctx, ¶ms, &search) - : cbm_zova_repository_search( - s->zova_repository, s->zova_graph_workspace_id, ¶ms, &search); - if (rc != CBM_STORE_OK) { - *out = NULL; - *count = 0; - return rc; - } - cbm_node_t *nodes = search.count ? calloc((size_t)search.count, sizeof(*nodes)) : NULL; - if (search.count && !nodes) { - cbm_store_search_free(&search); - return CBM_STORE_ERR; - } - int matched = 0; - for (int i = 0; i < search.count; i++) { - if (!search.results[i].node.name || strcmp(search.results[i].node.name, name) != 0) - continue; - nodes[matched++] = search.results[i].node; - memset(&search.results[i].node, 0, sizeof(search.results[i].node)); - } - *out = nodes; - *count = matched; - cbm_store_search_free(&search); - return CBM_STORE_OK; - } - return find_nodes_generic(s, &s->stmt_find_nodes_by_name, - "SELECT id, project, label, name, qualified_name, file_path, " - "start_line, end_line, properties FROM nodes " - "WHERE project = ?1 AND name = ?2;", - project, name, out, count); -} - -/* label NULL matches every label. */ -int cbm_store_find_nodes_by_label(cbm_store_t *s, const char *project, const char *label, - cbm_node_t **out, int *count) { - if (s && (s->backend || (s->zova_workspace_query && s->zova_repository))) { - cbm_search_output_t search = {0}; - cbm_search_params_t params = { - .project = project, .label = label, .min_degree = -1, .max_degree = -1, - .limit = INT_MAX}; - int rc = s->backend - ? s->backend->search(s->backend_ctx, ¶ms, &search) - : cbm_zova_repository_search(s->zova_repository, - s->zova_graph_workspace_id, ¶ms, &search); - if (rc != CBM_STORE_OK) { - *out = NULL; - *count = 0; - return rc; - } - cbm_node_t *nodes = search.count ? calloc((size_t)search.count, sizeof(*nodes)) : NULL; - if (search.count && !nodes) { - cbm_store_search_free(&search); - return CBM_STORE_ERR; - } - for (int i = 0; i < search.count; i++) { - nodes[i] = search.results[i].node; - memset(&search.results[i].node, 0, sizeof(search.results[i].node)); - } - if (search.count > 1) { - qsort(nodes, (size_t)search.count, sizeof(*nodes), node_public_order_compare); - } - *out = nodes; - *count = search.count; - cbm_store_search_free(&search); - return CBM_STORE_OK; - } - if (!label) { - return find_nodes_generic(s, &s->stmt_find_nodes, - "SELECT id, project, label, name, qualified_name, file_path, " - "start_line, end_line, properties FROM nodes " - "WHERE project = ?1 " - "ORDER BY qualified_name,file_path,start_line,end_line,id;", - project, NULL, out, count); - } - return find_nodes_generic(s, &s->stmt_find_nodes_by_label, - "SELECT id, project, label, name, qualified_name, file_path, " - "start_line, end_line, properties FROM nodes " - "WHERE project = ?1 AND label = ?2 " - "ORDER BY qualified_name,file_path,start_line,end_line,id;", - project, label, out, count); -} - -int cbm_store_find_nodes(cbm_store_t *s, const char *project, cbm_node_t **out, int *count) { - return cbm_store_find_nodes_by_label(s, project, NULL, out, count); -} - -int cbm_store_find_nodes_by_file(cbm_store_t *s, const char *project, const char *file_path, - cbm_node_t **out, int *count) { - if (s && s->zova_workspace_query && s->zova_repository) { - cbm_search_output_t search = {0}; - cbm_search_params_t params = { - .project = project, .file_pattern = file_path, - .min_degree = -1, .max_degree = -1, .limit = INT_MAX}; - int rc = cbm_zova_repository_search( - s->zova_repository, s->zova_graph_workspace_id, ¶ms, &search); - if (rc != CBM_STORE_OK) { - *out = NULL; - *count = 0; - return rc; - } - cbm_node_t *nodes = search.count ? calloc((size_t)search.count, sizeof(*nodes)) : NULL; - if (search.count && !nodes) { - cbm_store_search_free(&search); - return CBM_STORE_ERR; - } - int matched = 0; - for (int i = 0; i < search.count; i++) { - if (!search.results[i].node.file_path || - strcmp(search.results[i].node.file_path, file_path) != 0) - continue; - nodes[matched++] = search.results[i].node; - memset(&search.results[i].node, 0, sizeof(search.results[i].node)); - } - *out = nodes; - *count = matched; - cbm_store_search_free(&search); - return CBM_STORE_OK; - } - return find_nodes_generic(s, &s->stmt_find_nodes_by_file, - "SELECT id, project, label, name, qualified_name, file_path, " - "start_line, end_line, properties FROM nodes " - "WHERE project = ?1 AND file_path = ?2;", - project, file_path, out, count); -} - -static bool file_outline_is_container(const char *label) { - static const char *const containers[] = {"Module", "Package", "File", "Folder", "Project"}; - for (size_t i = 0; i < sizeof(containers) / sizeof(containers[0]); i++) { - if (strcmp(label, containers[i]) == 0) { - return true; - } - } - return false; -} - -static int file_outline_str_cmp(const char *a, const char *b) { - return strcmp(a ? a : "", b ? b : ""); -} - -static int file_outline_node_cmp(const void *pa, const void *pb) { - const cbm_node_t *a = *(const cbm_node_t *const *)pa; - const cbm_node_t *b = *(const cbm_node_t *const *)pb; - if (a->start_line != b->start_line) { - return a->start_line < b->start_line ? -1 : 1; - } - if (a->end_line != b->end_line) { - return a->end_line < b->end_line ? -1 : 1; - } - int c = file_outline_str_cmp(a->label, b->label); - if (c == 0) { - c = file_outline_str_cmp(a->name, b->name); - } - if (c == 0) { - c = file_outline_str_cmp(a->qualified_name, b->qualified_name); - } - if (c == 0 && a->id != b->id) { - c = a->id < b->id ? -1 : 1; - } - return c; -} - -int cbm_store_get_file_outline(cbm_store_t *s, const char *project, const char *file_path, - const char *const *labels, int label_count, int limit, int offset, - cbm_file_outline_row_t **out, int *count, int *total) { - if (!out || !count || !total) { - return CBM_STORE_ERR; - } - *out = NULL; - *count = 0; - *total = 0; - if (!s || !project || !file_path || !file_path[0] || limit < 1 || - limit > CBM_STORE_FILE_OUTLINE_MAX_LIMIT || offset < 0 || label_count < 0 || - label_count > CBM_STORE_FILE_OUTLINE_MAX_LABELS || (label_count > 0 && !labels)) { - return CBM_STORE_ERR; - } - for (int i = 0; i < label_count; i++) { - if (!labels[i] || !labels[i][0]) { - return CBM_STORE_ERR; - } - } - - cbm_node_t *nodes = NULL; - int node_count = 0; - int rc = cbm_store_find_nodes_by_file(s, project, file_path, &nodes, &node_count); - if (rc != CBM_STORE_OK) { - return rc; - } - const cbm_node_t **kept = node_count ? calloc((size_t)node_count, sizeof(*kept)) : NULL; - if (node_count && !kept) { - cbm_store_free_nodes(nodes, node_count); - return CBM_STORE_ERR; - } - int kept_count = 0; - for (int i = 0; i < node_count; i++) { - const char *label = nodes[i].label ? nodes[i].label : ""; - if (file_outline_is_container(label)) { - continue; - } - bool wanted = label_count == 0; - for (int j = 0; j < label_count && !wanted; j++) { - wanted = strcmp(label, labels[j]) == 0; - } - if (wanted) { - kept[kept_count++] = &nodes[i]; - } - } - if (kept_count > 1) { - qsort(kept, (size_t)kept_count, sizeof(*kept), file_outline_node_cmp); - } - - int first = offset < kept_count ? offset : kept_count; - int page = kept_count - first < limit ? kept_count - first : limit; - size_t text_bytes = 0; - for (int i = first; i < first + page; i++) { - text_bytes += strlen(kept[i]->name ? kept[i]->name : "") + - strlen(kept[i]->label ? kept[i]->label : "") + - strlen(kept[i]->qualified_name ? kept[i]->qualified_name : ""); - } - if (text_bytes > CBM_STORE_FILE_OUTLINE_MAX_TEXT_BYTES) { - free(kept); - cbm_store_free_nodes(nodes, node_count); - return CBM_STORE_SCAN_LIMIT; - } - - cbm_file_outline_row_t *rows = page ? calloc((size_t)page, sizeof(*rows)) : NULL; - rc = page && !rows ? CBM_STORE_ERR : CBM_STORE_OK; - for (int i = 0; rc == CBM_STORE_OK && i < page; i++) { - const cbm_node_t *n = kept[first + i]; - rows[i].name = cbm_heap_strdup(n->name ? n->name : ""); - rows[i].label = cbm_heap_strdup(n->label ? n->label : ""); - rows[i].qualified_name = cbm_heap_strdup(n->qualified_name ? n->qualified_name : ""); - rows[i].start_line = n->start_line; - rows[i].end_line = n->end_line; - if (!rows[i].name || !rows[i].label || !rows[i].qualified_name) { - cbm_store_free_file_outline(rows, i + 1); - rows = NULL; - rc = CBM_STORE_ERR; - } - } - free(kept); - cbm_store_free_nodes(nodes, node_count); - if (rc != CBM_STORE_OK) { - return rc; - } - *out = rows; - *count = page; - *total = kept_count; - return CBM_STORE_OK; -} - -void cbm_store_free_file_outline(cbm_file_outline_row_t *rows, int count) { - if (!rows) { - return; - } - for (int i = 0; i < count; i++) { - free((void *)rows[i].name); - free((void *)rows[i].label); - free((void *)rows[i].qualified_name); - } - free(rows); -} - -/* ── Graph comparison ──────────────────────────────────────────── */ - -typedef struct { - const char *key_qn; /* qualified_name without the leading "." */ - const cbm_zova_identity_node_t *row; -} graph_cmp_node_t; - -typedef struct { - const graph_cmp_node_t *source; - const graph_cmp_node_t *target; - const cbm_zova_identity_edge_t *row; -} graph_cmp_edge_t; - -typedef struct { - cbm_zova_identity_node_t *rows; - int row_count; - cbm_zova_identity_edge_t *edge_rows; - int edge_row_count; - graph_cmp_node_t *nodes; /* identity order */ - graph_cmp_node_t *by_key; /* node-key order, for edge endpoints */ - graph_cmp_edge_t *edges; /* identity order */ -} graph_cmp_side_t; - -static const char *graph_cmp_strip_project(const char *qn, const char *project) { - size_t n = strlen(project); - if (strncmp(qn, project, n) == 0) { - if (qn[n] == '.') { - return qn + n + 1; - } - if (qn[n] == '\0') { - return qn + n; - } - } - return qn; -} - -static int graph_cmp_node_identity(const graph_cmp_node_t *a, const graph_cmp_node_t *b) { - int cmp = strcmp(a->key_qn, b->key_qn); - if (cmp == 0) { - cmp = strcmp(a->row->label, b->row->label); - } - if (cmp == 0) { - cmp = strcmp(a->row->file_path, b->row->file_path); - } - return cmp; -} - -static int graph_cmp_node_qsort(const void *a, const void *b) { - return graph_cmp_node_identity(a, b); -} - -static int graph_cmp_edge_identity(const graph_cmp_edge_t *a, const graph_cmp_edge_t *b) { - int cmp = graph_cmp_node_identity(a->source, b->source); - if (cmp == 0) { - cmp = graph_cmp_node_identity(a->target, b->target); - } - if (cmp == 0) { - cmp = strcmp(a->row->type, b->row->type); - } - if (cmp == 0) { - cmp = strcmp(a->row->local_name, b->row->local_name); - } - return cmp; -} - -static int graph_cmp_edge_qsort(const void *a, const void *b) { - return graph_cmp_edge_identity(a, b); -} - -static int graph_cmp_key_bsearch(const void *key, const void *elem) { - int64_t k = *(const int64_t *)key; - int64_t e = ((const graph_cmp_node_t *)elem)->row->node_key; - return (k > e) - (k < e); -} - -static void graph_cmp_side_free(graph_cmp_side_t *side) { - cbm_zova_identities_free(side->rows, side->row_count, side->edge_rows, side->edge_row_count); - free(side->nodes); - free(side->by_key); - free(side->edges); - memset(side, 0, sizeof(*side)); -} - -/* Load one side and order its nodes and edges by identity. */ -static int graph_cmp_side_load(cbm_store_t *s, const char *project, uint64_t max_nodes, - uint64_t max_edges, graph_cmp_side_t *side, - cbm_graph_compare_project_t *meta) { - if (!s || !s->zova_workspace_query || !s->zova_repository || !project) { - return CBM_STORE_ERR; - } - char *indexed_at = NULL; - if (cbm_zova_repository_index_status(s->zova_repository, s->zova_graph_workspace_id, - &meta->generation, &indexed_at) != CBM_STORE_OK) { - free(indexed_at); - return CBM_STORE_ERR; - } - snprintf(meta->indexed_at, sizeof(meta->indexed_at), "%s", indexed_at ? indexed_at : ""); - free(indexed_at); - int rc = cbm_zova_repository_graph_identities( - s->zova_repository, s->zova_graph_workspace_id, max_nodes, max_edges, &side->rows, - &side->row_count, &side->edge_rows, &side->edge_row_count); - if (rc != CBM_STORE_OK) { - return rc; - } - meta->node_count = side->row_count; - meta->edge_count = side->edge_row_count; - size_t nn = (size_t)side->row_count; - size_t ne = (size_t)side->edge_row_count; - side->nodes = nn ? malloc(nn * sizeof(*side->nodes)) : NULL; - side->by_key = nn ? malloc(nn * sizeof(*side->by_key)) : NULL; - side->edges = ne ? malloc(ne * sizeof(*side->edges)) : NULL; - if ((nn && (!side->nodes || !side->by_key)) || (ne && !side->edges)) { - return CBM_STORE_ERR; - } - for (size_t i = 0; i < nn; i++) { - for (char *p = side->rows[i].file_path; *p; p++) { - if (*p == '\\') { - *p = '/'; - } - } - side->by_key[i] = (graph_cmp_node_t){ - graph_cmp_strip_project(side->rows[i].qualified_name, project), &side->rows[i]}; - } - /* Rows arrive in node-key order, so by_key is already sorted. */ - memcpy(side->nodes, side->by_key, nn * sizeof(*side->nodes)); - qsort(side->nodes, nn, sizeof(*side->nodes), graph_cmp_node_qsort); - for (size_t i = 0; i < ne; i++) { - const cbm_zova_identity_edge_t *row = &side->edge_rows[i]; - const graph_cmp_node_t *source = - bsearch(&row->source_key, side->by_key, nn, sizeof(*side->by_key), - graph_cmp_key_bsearch); - const graph_cmp_node_t *target = - bsearch(&row->target_key, side->by_key, nn, sizeof(*side->by_key), - graph_cmp_key_bsearch); - if (!source || !target) { - return CBM_STORE_ERR; /* an edge endpoint outside the generation */ - } - side->edges[i] = (graph_cmp_edge_t){source, target, row}; - } - qsort(side->edges, ne, sizeof(*side->edges), graph_cmp_edge_qsort); - return CBM_STORE_OK; -} - -static cbm_graph_node_identity_t graph_cmp_node_out(const graph_cmp_node_t *node) { - return (cbm_graph_node_identity_t){node->row->qualified_name, node->row->label, - node->row->file_path}; -} - -static bool graph_cmp_emit_node(cbm_graph_compare_node_fn fn, void *context, bool added, - const graph_cmp_node_t *node, uint64_t *total) { - (*total)++; - cbm_graph_node_identity_t id = graph_cmp_node_out(node); - return !fn || fn(context, added, &id); -} - -static bool graph_cmp_emit_edge(cbm_graph_compare_edge_fn fn, void *context, bool added, - const graph_cmp_edge_t *edge, uint64_t *total) { - (*total)++; - cbm_graph_edge_identity_t id = {graph_cmp_node_out(edge->source), - graph_cmp_node_out(edge->target), edge->row->type, - edge->row->local_name}; - return !fn || fn(context, added, &id); -} - -int cbm_store_compare_graphs(cbm_store_t *base_store, const char *base_project, - cbm_store_t *target_store, const char *target_project, - uint64_t scan_limit, cbm_graph_compare_node_fn on_node, - cbm_graph_compare_edge_fn on_edge, void *context, - cbm_graph_compare_result_t *out) { - if (!out) { - return CBM_STORE_ERR; - } - memset(out, 0, sizeof(*out)); - graph_cmp_side_t base = {0}; - graph_cmp_side_t target = {0}; - int rc = graph_cmp_side_load(base_store, base_project, scan_limit, scan_limit, &base, - &out->base); - if (rc == CBM_STORE_OK) { - rc = graph_cmp_side_load(target_store, target_project, - scan_limit - (uint64_t)base.row_count, - scan_limit - (uint64_t)base.edge_row_count, &target, - &out->target); - } - bool ok = rc == CBM_STORE_OK; - int i = 0; - int j = 0; - while (ok && (i < base.row_count || j < target.row_count)) { - int cmp = i >= base.row_count ? 1 - : j >= target.row_count ? -1 - : graph_cmp_node_identity(&base.nodes[i], - &target.nodes[j]); - if (cmp < 0) { - ok = graph_cmp_emit_node(on_node, context, false, &base.nodes[i++], - &out->nodes_removed_total); - } else if (cmp > 0) { - ok = graph_cmp_emit_node(on_node, context, true, &target.nodes[j++], - &out->nodes_added_total); - } else { - i++; - j++; - } - } - i = 0; - j = 0; - while (ok && (i < base.edge_row_count || j < target.edge_row_count)) { - int cmp = i >= base.edge_row_count ? 1 - : j >= target.edge_row_count ? -1 - : graph_cmp_edge_identity(&base.edges[i], - &target.edges[j]); - if (cmp < 0) { - ok = graph_cmp_emit_edge(on_edge, context, false, &base.edges[i++], - &out->edges_removed_total); - } else if (cmp > 0) { - ok = graph_cmp_emit_edge(on_edge, context, true, &target.edges[j++], - &out->edges_added_total); - } else { - i++; - j++; - } - } - if (rc == CBM_STORE_OK && !ok) { - rc = CBM_STORE_ERR; - } - graph_cmp_side_free(&base); - graph_cmp_side_free(&target); - return rc; -} - -/* A Zova workspace store has no legacy nodes and edges tables. The schema, - * count and architecture queries below read nodes through a temp view over the - * workspace's rows, whose ids are Zova node keys; edges come from the graph - * through store_zova_edges. The store holds one workspace, so *project is - * replaced by the name the rows carry. Does nothing for other stores. */ -static int store_legacy_scope(cbm_store_t *s, const char **project) { - if (!s || !s->db || !s->zova_workspace_query || !s->zova_repository) return CBM_STORE_OK; - if (!s->zova_legacy_project) { - cbm_project_t row = {0}; - if (cbm_zova_repository_get_project(s->zova_repository, s->zova_graph_workspace_id, - &row) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - char *sql = sqlite3_mprintf( - "CREATE TEMP VIEW nodes AS SELECT n.zova_node_key AS id,%Q AS project,n.label," - "n.name,n.qualified_name,f.file_path,n.start_line,n.end_line,n.properties " - "FROM main.cbm_nodes_v1 n JOIN main.cbm_files_v1 f ON f.file_key=n.file_key " - "WHERE n.workspace_key=(SELECT workspace_key FROM main.cbm_workspace_registry " - "WHERE workspace_id=%Q)", - row.name, s->zova_graph_workspace_id); - int rc = sql ? sqlite3_exec(s->db, sql, NULL, NULL, NULL) : SQLITE_NOMEM; - sqlite3_free(sql); - if (rc == SQLITE_OK) { - s->zova_legacy_project = cbm_heap_strdup(row.name); - } - cbm_project_free_fields(&row); - if (!s->zova_legacy_project) { - store_set_error_sqlite(s, "zova legacy nodes"); - return CBM_STORE_ERR; - } - } - *project = s->zova_legacy_project; - return CBM_STORE_OK; -} - -static bool store_is_zova_workspace(const cbm_store_t *s) { - return s && s->zova_workspace_query && s->zova_repository; -} - -typedef struct { - cbm_store_t *store; - int capacity; -} store_zova_edge_load_t; - -static int store_zova_edge_type_find(const cbm_store_t *s, const char *type, size_t type_len) { - for (int i = 0; i < s->zova_edge_type_count; i++) { - if (strlen(s->zova_edge_types[i]) == type_len && - memcmp(s->zova_edge_types[i], type, type_len) == 0) { - return i; - } - } - return CBM_NOT_FOUND; -} - -static int store_zova_edge_load(int64_t source_key, int64_t target_key, const char *type, - size_t type_len, const uint8_t *properties, size_t properties_len, - void *context_ptr) { - (void)properties; - (void)properties_len; - store_zova_edge_load_t *context = context_ptr; - cbm_store_t *s = context->store; - int t = store_zova_edge_type_find(s, type, type_len); - if (t < 0) { - char **types = realloc(s->zova_edge_types, - (size_t)(s->zova_edge_type_count + SKIP_ONE) * sizeof(*types)); - char *name = types ? malloc(type_len + SKIP_ONE) : NULL; - if (types) { - s->zova_edge_types = types; - } - if (!name) { - return CBM_NOT_FOUND; - } - memcpy(name, type, type_len); - name[type_len] = '\0'; - t = s->zova_edge_type_count; - s->zova_edge_types[s->zova_edge_type_count++] = name; - } - if (s->zova_edge_count == context->capacity) { - int capacity = context->capacity ? context->capacity * ST_GROWTH : CBM_SZ_1K; - store_zova_edge_t *edges = realloc(s->zova_edges, (size_t)capacity * sizeof(*edges)); - if (!edges) { - return CBM_NOT_FOUND; - } - s->zova_edges = edges; - context->capacity = capacity; - } - s->zova_edges[s->zova_edge_count++] = - (store_zova_edge_t){.source_key = source_key, .target_key = target_key, .type = t}; - return 0; -} - -/* The workspace's cbm edges, read once per store from the Zova graph in scan - * order. Schema, count and architecture reads aggregate this list instead of - * querying an edges table. */ -static int store_zova_edges(cbm_store_t *s) { - if (s->zova_edges_loaded) { - return CBM_STORE_OK; - } - store_zova_edge_load_t context = {.store = s}; - if (cbm_zova_repository_visit_edges(s->zova_repository, s->zova_graph_workspace_id, NULL, - store_zova_edge_load, &context) != CBM_STORE_OK) { - free(s->zova_edges); - s->zova_edges = NULL; - s->zova_edge_count = 0; - store_set_error(s, "zova edges"); - return CBM_STORE_ERR; - } - s->zova_edges_loaded = true; - return CBM_STORE_OK; -} - -static int store_id_compare(const void *left_ptr, const void *right_ptr) { - int64_t left = *(const int64_t *)left_ptr; - int64_t right = *(const int64_t *)right_ptr; - return (left > right) - (left < right); -} - -static bool store_id_in(const int64_t *ids, int count, int64_t id) { - return count > 0 && bsearch(&id, ids, (size_t)count, sizeof(*ids), store_id_compare); -} - -int cbm_store_count_nodes(cbm_store_t *s, const char *project) { - if (!s || !s->db) { - return 0; - } - if (store_legacy_scope(s, &project) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_count_nodes, "SELECT COUNT(*) FROM nodes WHERE project = ?1;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - if (sqlite3_step(stmt) == SQLITE_ROW) { - return sqlite3_column_int(stmt, 0); - } - return 0; -} - -int cbm_store_delete_nodes_by_project(cbm_store_t *s, const char *project) { - sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_delete_nodes_by_project, - "DELETE FROM nodes WHERE project = ?1;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - if (sqlite3_step(stmt) != SQLITE_DONE) { - store_set_error_sqlite(s, "delete_nodes_by_project"); - return CBM_STORE_ERR; - } - return CBM_STORE_OK; -} - -int cbm_store_delete_nodes_by_file(cbm_store_t *s, const char *project, const char *file_path) { - sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_delete_nodes_by_file, - "DELETE FROM nodes WHERE project = ?1 AND file_path = ?2;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - bind_text(stmt, ST_COL_2, file_path); - if (sqlite3_step(stmt) != SQLITE_DONE) { - store_set_error_sqlite(s, "delete_nodes_by_file"); - return CBM_STORE_ERR; - } - return CBM_STORE_OK; -} - -int cbm_store_delete_nodes_by_label(cbm_store_t *s, const char *project, const char *label) { - sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_delete_nodes_by_label, - "DELETE FROM nodes WHERE project = ?1 AND label = ?2;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - bind_text(stmt, ST_COL_2, label); - if (sqlite3_step(stmt) != SQLITE_DONE) { - store_set_error_sqlite(s, "delete_nodes_by_label"); - return CBM_STORE_ERR; - } - return CBM_STORE_OK; -} - -/* ── Node batch ─────────────────────────────────────────────────── */ - -int cbm_store_upsert_node_batch(cbm_store_t *s, const cbm_node_t *nodes, int count, - int64_t *out_ids) { - if (count == 0) { - return CBM_STORE_OK; - } - - exec_sql(s, "BEGIN IMMEDIATE;"); - for (int i = 0; i < count; i++) { - int64_t id = cbm_store_upsert_node(s, &nodes[i]); - if (id == CBM_STORE_ERR) { - exec_sql(s, "ROLLBACK;"); - return CBM_STORE_ERR; - } - if (out_ids) { - out_ids[i] = id; - } - } - exec_sql(s, "COMMIT;"); - return CBM_STORE_OK; -} - -/* ── Edge CRUD ──────────────────────────────────────────────────── */ - -int64_t cbm_store_insert_edge(cbm_store_t *s, const cbm_edge_t *e) { - /* Conflict target includes local_name_gen (#768) so IMPORTS edges with - * different local_name coexist while re-inserting the same import still - * upserts. Must match the table's UNIQUE constraint in init_schema. */ - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_insert_edge, - "INSERT INTO edges (project, source_id, target_id, type, properties) " - "VALUES (?1, ?2, ?3, ?4, ?5) " - "ON CONFLICT(source_id, target_id, type, local_name_gen) DO UPDATE SET " - "properties = json_patch(properties, ?5) " - "RETURNING id;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, safe_str(e->project)); - sqlite3_bind_int64(stmt, PAIR_LEN, e->source_id); - sqlite3_bind_int64(stmt, CBM_SZ_3, e->target_id); - bind_text(stmt, ST_COL_4, safe_str(e->type)); - bind_text(stmt, ST_COL_5, safe_props(e->properties_json)); - - int rc = sqlite3_step(stmt); - if (rc == SQLITE_ROW) { - int64_t id = sqlite3_column_int64(stmt, 0); - sqlite3_reset(stmt); /* unblock COMMIT — RETURNING leaves stmt active */ - return id; - } - sqlite3_reset(stmt); - store_set_error_sqlite(s, "insert_edge"); - return CBM_STORE_ERR; -} - -/* Scan an edge from current row of stmt. */ -static void scan_edge(sqlite3_stmt *stmt, cbm_edge_t *e) { - e->id = sqlite3_column_int64(stmt, 0); - e->project = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE)); - e->source_id = sqlite3_column_int64(stmt, CBM_SZ_2); - e->target_id = sqlite3_column_int64(stmt, CBM_SZ_3); - e->type = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_4)); - e->properties_json = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_5)); -} - -/* Generic: find multiple edges by a filter. */ -static int find_edges_generic(cbm_store_t *s, sqlite3_stmt **slot, const char *sql, - void (*bind_fn)(sqlite3_stmt *, const void *), const void *bind_data, - cbm_edge_t **out, int *count) { - if (!s || !s->db) { - *out = NULL; - *count = 0; - return CBM_STORE_ERR; - } - sqlite3_stmt *stmt = prepare_cached(s, slot, sql); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_fn(stmt, bind_data); - - int cap = ST_INIT_CAP_16; - int n = 0; - cbm_edge_t *arr = malloc(cap * sizeof(cbm_edge_t)); - - int scan_rc4; - while ((scan_rc4 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - arr = safe_realloc(arr, cap * sizeof(cbm_edge_t)); - } - scan_edge(stmt, &arr[n]); - n++; - } - if (scan_rc4 != SQLITE_DONE) { /* SCANCHK:4:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - cbm_store_free_edges(arr, n); - *out = NULL; - *count = 0; - return CBM_STORE_ERR; - } - - *out = arr; - *count = n; - return CBM_STORE_OK; -} - -/* Bind helpers for edge queries */ -typedef struct { - int64_t id; -} bind_id_t; -typedef struct { - int64_t id; - const char *type; -} bind_id_type_t; -typedef struct { - const char *project; - const char *type; -} bind_proj_type_t; - -static void bind_source_id(sqlite3_stmt *stmt, const void *data) { - const bind_id_t *b = data; - sqlite3_bind_int64(stmt, SKIP_ONE, b->id); -} - -static void bind_id_and_type(sqlite3_stmt *stmt, const void *data) { - const bind_id_type_t *b = data; - sqlite3_bind_int64(stmt, SKIP_ONE, b->id); - bind_text(stmt, ST_COL_2, b->type); -} - -static void bind_proj_and_type(sqlite3_stmt *stmt, const void *data) { - const bind_proj_type_t *b = data; - bind_text(stmt, SKIP_ONE, b->project); - bind_text(stmt, ST_COL_2, b->type); -} - -static int store_zova_workspace_edges(cbm_store_t *s, int64_t node_numeric_id, - const char *direction, const char *type, - cbm_edge_t **out, int *count) { - if (!s || !s->zova_workspace_query || !s->zova_repository || !out || !count) - return CBM_STORE_NOT_FOUND; - *out = NULL; *count = 0; - char stable[CBM_ZOVA_WORKSPACE_ID_MAX+96]={0}; - int rc=cbm_zova_repository_stable_id_for_numeric_id( - s->zova_repository,s->zova_graph_workspace_id,node_numeric_id,stable,sizeof(stable)); - if(rc!=CBM_STORE_OK)return rc; - cbm_edge_t *edges=NULL;int edge_count=0; - rc=cbm_zova_repository_find_edges(s->zova_repository,s->zova_graph_workspace_id, - stable,direction,&edges,&edge_count); - if(rc!=CBM_STORE_OK)return rc; - if(type){ - int kept=0; - for(int i=0;izova_native_topology_ops++;return CBM_STORE_OK; -} - -int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out, - int *count) { - if (s && s->backend) - return s->backend->find_edges(s->backend_ctx, source_id, "outbound", NULL, out, count); - if (s && s->zova_workspace_query) - return store_zova_workspace_edges(s,source_id,"outbound",NULL,out,count); - bind_id_t b = {source_id}; - return find_edges_generic(s, &s->stmt_find_edges_by_source, - "SELECT id, project, source_id, target_id, type, properties " - "FROM edges WHERE source_id = ?1;", - bind_source_id, &b, out, count); -} - -int cbm_store_find_edges_by_target(cbm_store_t *s, int64_t target_id, cbm_edge_t **out, - int *count) { - if (s && s->backend) - return s->backend->find_edges(s->backend_ctx, target_id, "inbound", NULL, out, count); - if (s && s->zova_workspace_query) - return store_zova_workspace_edges(s,target_id,"inbound",NULL,out,count); - bind_id_t b = {target_id}; - return find_edges_generic(s, &s->stmt_find_edges_by_target, - "SELECT id, project, source_id, target_id, type, properties " - "FROM edges WHERE target_id = ?1;", - bind_source_id, &b, out, count); -} - -int cbm_store_find_edges_by_source_type(cbm_store_t *s, int64_t source_id, const char *type, - cbm_edge_t **out, int *count) { - if (s && s->backend) - return s->backend->find_edges(s->backend_ctx, source_id, "outbound", type, out, count); - if (s && s->zova_workspace_query) - return store_zova_workspace_edges(s,source_id,"outbound",type,out,count); - bind_id_type_t b = {source_id, type}; - return find_edges_generic(s, &s->stmt_find_edges_by_source_type, - "SELECT id, project, source_id, target_id, type, properties " - "FROM edges WHERE source_id = ?1 AND type = ?2;", - bind_id_and_type, &b, out, count); -} - -int cbm_store_find_edges_by_target_type(cbm_store_t *s, int64_t target_id, const char *type, - cbm_edge_t **out, int *count) { - if (s && s->backend) - return s->backend->find_edges(s->backend_ctx, target_id, "inbound", type, out, count); - if (s && s->zova_workspace_query) - return store_zova_workspace_edges(s,target_id,"inbound",type,out,count); - bind_id_type_t b = {target_id, type}; - return find_edges_generic(s, &s->stmt_find_edges_by_target_type, - "SELECT id, project, source_id, target_id, type, properties " - "FROM edges WHERE target_id = ?1 AND type = ?2;", - bind_id_and_type, &b, out, count); -} - -int cbm_store_find_edges_by_type(cbm_store_t *s, const char *project, const char *type, - cbm_edge_t **out, int *count) { - if (store_is_zova_workspace(s)) { - return cbm_zova_repository_find_edges_by_type(s->zova_repository, - s->zova_graph_workspace_id, type, out, count); - } - bind_proj_type_t b = {project, type}; - return find_edges_generic(s, &s->stmt_find_edges_by_type, - "SELECT id, project, source_id, target_id, type, properties " - "FROM edges WHERE project = ?1 AND type = ?2;", - bind_proj_and_type, &b, out, count); -} - -int cbm_store_count_edges(cbm_store_t *s, const char *project) { - if (!s || !s->db) { - return 0; - } - if (s->zova_workspace_query && s->zova_repository) { - int nodes = 0; - int edges = 0; - return cbm_zova_repository_counts(s->zova_repository, s->zova_graph_workspace_id, &nodes, - &edges) == CBM_STORE_OK - ? edges - : CBM_STORE_ERR; - } - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_count_edges, "SELECT COUNT(*) FROM edges WHERE project = ?1;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - if (sqlite3_step(stmt) == SQLITE_ROW) { - return sqlite3_column_int(stmt, 0); - } - return 0; -} - -int cbm_store_count_edges_by_type(cbm_store_t *s, const char *project, const char *type) { - if (store_is_zova_workspace(s)) { - if (store_zova_edges(s) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - int t = type ? store_zova_edge_type_find(s, type, strlen(type)) : CBM_NOT_FOUND; - int n = 0; - for (int i = 0; t >= 0 && i < s->zova_edge_count; i++) { - n += s->zova_edges[i].type == t; - } - return n; - } - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_count_edges_by_type, - "SELECT COUNT(*) FROM edges WHERE project = ?1 AND type = ?2;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - bind_text(stmt, ST_COL_2, type); - if (sqlite3_step(stmt) == SQLITE_ROW) { - return sqlite3_column_int(stmt, 0); - } - return 0; -} - -int cbm_store_delete_edges_by_project(cbm_store_t *s, const char *project) { - sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_delete_edges_by_project, - "DELETE FROM edges WHERE project = ?1;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - if (sqlite3_step(stmt) != SQLITE_DONE) { - store_set_error_sqlite(s, "delete_edges_by_project"); - return CBM_STORE_ERR; - } - return CBM_STORE_OK; -} - -int cbm_store_delete_edges_by_type(cbm_store_t *s, const char *project, const char *type) { - sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_delete_edges_by_type, - "DELETE FROM edges WHERE project = ?1 AND type = ?2;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - bind_text(stmt, ST_COL_2, type); - if (sqlite3_step(stmt) != SQLITE_DONE) { - store_set_error_sqlite(s, "delete_edges_by_type"); - return CBM_STORE_ERR; - } - return CBM_STORE_OK; -} - -/* ── Edge batch ─────────────────────────────────────────────────── */ - -int cbm_store_insert_edge_batch(cbm_store_t *s, const cbm_edge_t *edges, int count) { - if (count == 0) { - return CBM_STORE_OK; - } - - exec_sql(s, "BEGIN IMMEDIATE;"); - for (int i = 0; i < count; i++) { - int64_t id = cbm_store_insert_edge(s, &edges[i]); - if (id == CBM_STORE_ERR) { - exec_sql(s, "ROLLBACK;"); - return CBM_STORE_ERR; - } - } - exec_sql(s, "COMMIT;"); - return CBM_STORE_OK; -} - -/* ── File hash CRUD ─────────────────────────────────────────────── */ - -int cbm_store_upsert_file_hash(cbm_store_t *s, const char *project, const char *rel_path, - const char *sha256, int64_t mtime_ns, int64_t size) { - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_upsert_file_hash, - "INSERT INTO file_hashes (project, rel_path, sha256, mtime_ns, size) " - "VALUES (?1, ?2, ?3, ?4, ?5) " - "ON CONFLICT(project, rel_path) DO UPDATE SET " - "sha256=?3, mtime_ns=?4, size=?5;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - bind_text(stmt, ST_COL_2, rel_path); - bind_text(stmt, ST_COL_3, sha256); - sqlite3_bind_int64(stmt, CBM_SZ_4, mtime_ns); - sqlite3_bind_int64(stmt, CBM_SZ_5, size); - - if (sqlite3_step(stmt) != SQLITE_DONE) { - store_set_error_sqlite(s, "upsert_file_hash"); - return CBM_STORE_ERR; - } - return CBM_STORE_OK; -} - -int cbm_store_get_file_hashes(cbm_store_t *s, const char *project, cbm_file_hash_t **out, - int *count) { - sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_get_file_hashes, - "SELECT project, rel_path, sha256, mtime_ns, size " - "FROM file_hashes WHERE project = ?1;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - - int cap = ST_INIT_CAP_16; - int n = 0; - cbm_file_hash_t *arr = malloc(cap * sizeof(cbm_file_hash_t)); - - int scan_rc5; - while ((scan_rc5 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - arr = safe_realloc(arr, cap * sizeof(cbm_file_hash_t)); - } - arr[n].project = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, 0)); - arr[n].rel_path = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE)); - arr[n].sha256 = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_2)); - arr[n].mtime_ns = sqlite3_column_int64(stmt, CBM_SZ_3); - arr[n].size = sqlite3_column_int64(stmt, CBM_SZ_4); - n++; - } - if (scan_rc5 != SQLITE_DONE) { /* SCANCHK:5:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - cbm_store_free_file_hashes(arr, n); - *out = NULL; - *count = 0; - return CBM_STORE_ERR; - } - - *out = arr; - *count = n; - return CBM_STORE_OK; -} - -int cbm_store_delete_file_hash(cbm_store_t *s, const char *project, const char *rel_path) { - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_delete_file_hash, - "DELETE FROM file_hashes WHERE project = ?1 AND rel_path = ?2;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - bind_text(stmt, ST_COL_2, rel_path); - if (sqlite3_step(stmt) != SQLITE_DONE) { - store_set_error_sqlite(s, "delete_file_hash"); - return CBM_STORE_ERR; - } - return CBM_STORE_OK; -} - -int cbm_store_delete_file_hashes(cbm_store_t *s, const char *project) { - sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_delete_file_hashes, - "DELETE FROM file_hashes WHERE project = ?1;"); - if (!stmt) { - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - if (sqlite3_step(stmt) != SQLITE_DONE) { - store_set_error_sqlite(s, "delete_file_hashes"); - return CBM_STORE_ERR; - } - return CBM_STORE_OK; -} - -/* ── FindNodesByFileOverlap ─────────────────────────────────────── */ - -int cbm_store_find_nodes_by_file_overlap(cbm_store_t *s, const char *project, const char *file_path, - int start_line, int end_line, cbm_node_t **out, - int *count) { - if (s && s->zova_workspace_query && s->zova_repository) - return cbm_zova_repository_find_nodes_by_file_overlap( - s->zova_repository, s->zova_graph_workspace_id, file_path, - start_line, end_line, out, count); - *out = NULL; - *count = 0; - const char *sql = "SELECT id, project, label, name, qualified_name, file_path, " - "start_line, end_line, properties FROM nodes " - "WHERE project = ?1 AND file_path = ?2 " - "AND label NOT IN ('Module', 'Package', 'File', 'Folder') " - "AND start_line <= ?4 AND end_line >= ?3 " - "ORDER BY start_line"; - - sqlite3_stmt *stmt = NULL; - int rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL); - if (rc != SQLITE_OK) { - store_set_error_sqlite(s, "overlap prepare"); - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - bind_text(stmt, ST_COL_2, file_path); - sqlite3_bind_int(stmt, ST_COL_3, start_line); - sqlite3_bind_int(stmt, ST_COL_4, end_line); - - int cap = ST_INIT_CAP_8; - int n = 0; - cbm_node_t *nodes = malloc(cap * sizeof(cbm_node_t)); - int scan_rc7; - while ((scan_rc7 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - nodes = safe_realloc(nodes, cap * sizeof(cbm_node_t)); - } - memset(&nodes[n], 0, sizeof(cbm_node_t)); - scan_node(stmt, &nodes[n]); - n++; - } - if (scan_rc7 != SQLITE_DONE) { /* SCANCHK:7:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - cbm_store_free_nodes(nodes, n); - *out = NULL; - *count = 0; - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - *out = nodes; - *count = n; - return CBM_STORE_OK; -} - -/* ── FindNodesByQNSuffix ───────────────────────────────────────── */ - -int cbm_store_find_nodes_by_qn_suffix(cbm_store_t *s, const char *project, const char *suffix, - cbm_node_t **out, int *count) { - *out = NULL; - *count = 0; - if (!s || !s->db) { - return CBM_STORE_ERR; - } - if (store_legacy_scope(s, &project) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - /* Match QNs ending with ".suffix" or exactly equal to suffix */ - char like_pattern[CBM_SZ_512]; - snprintf(like_pattern, sizeof(like_pattern), "%%.%s", suffix); - - const char *sql_with_project = - "SELECT id, project, label, name, qualified_name, file_path, " - "start_line, end_line, properties FROM nodes " - "WHERE project = ?1 AND (qualified_name LIKE ?2 OR qualified_name = ?3)"; - const char *sql_any = "SELECT id, project, label, name, qualified_name, file_path, " - "start_line, end_line, properties FROM nodes " - "WHERE (qualified_name LIKE ?1 OR qualified_name = ?2)"; - - sqlite3_stmt *stmt = NULL; - int rc = - sqlite3_prepare_v2(s->db, project ? sql_with_project : sql_any, CBM_NOT_FOUND, &stmt, NULL); - if (rc != SQLITE_OK) { - store_set_error_sqlite(s, "qn_suffix prepare"); - return CBM_STORE_ERR; - } - - if (project) { - bind_text(stmt, SKIP_ONE, project); - bind_text(stmt, ST_COL_2, like_pattern); - bind_text(stmt, ST_COL_3, suffix); - } else { - bind_text(stmt, SKIP_ONE, like_pattern); - bind_text(stmt, ST_COL_2, suffix); - } - - int cap = ST_INIT_CAP_8; - int n = 0; - cbm_node_t *nodes = malloc(cap * sizeof(cbm_node_t)); - int scan_rc8; - while ((scan_rc8 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - nodes = safe_realloc(nodes, cap * sizeof(cbm_node_t)); - } - memset(&nodes[n], 0, sizeof(cbm_node_t)); - scan_node(stmt, &nodes[n]); - n++; - } - if (scan_rc8 != SQLITE_DONE) { /* SCANCHK:8:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - cbm_store_free_nodes(nodes, n); - *out = NULL; - *count = 0; - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - if (s->zova_workspace_query && s->zova_repository) { - /* The view's ids are Zova node keys; callers expect repository ids. */ - for (int i = 0; i < n; i++) { - cbm_node_t resolved = {0}; - if (cbm_zova_repository_find_node_by_qn(s->zova_repository, - s->zova_graph_workspace_id, - nodes[i].qualified_name, - &resolved) != CBM_STORE_OK) { - cbm_store_free_nodes(nodes, n); - return CBM_STORE_ERR; - } - cbm_node_free_fields(&nodes[i]); - nodes[i] = resolved; - } - } - *out = nodes; - *count = n; - return CBM_STORE_OK; -} - -/* ── NodeDegree ────────────────────────────────────────────────── */ - -void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *out_deg) { - if (!s) { - if (in_deg) - *in_deg = 0; - if (out_deg) - *out_deg = 0; - return; - } - *in_deg = 0; - *out_deg = 0; - - if (s->backend) { - if (s->backend->node_degree(s->backend_ctx, node_id, in_deg, out_deg) != CBM_STORE_OK) { - *in_deg = 0; - *out_deg = 0; - } - return; - } - if (s->zova_workspace_query) { - char stable[CBM_ZOVA_WORKSPACE_ID_MAX + 96] = {0}; - char graph[CBM_ZOVA_WORKSPACE_ID_MAX + 32]; - if (!s->zova_graph_session || !s->zova_repository || - cbm_zova_repository_stable_id_for_numeric_id( - s->zova_repository, s->zova_graph_workspace_id, node_id, - stable, sizeof(stable)) != CBM_STORE_OK) { - return; - } - if (!stable[0] || - cbm_zova_workspace_graph_name(s->zova_graph_workspace_id, graph, - sizeof(graph)) != 0) { - return; - } - const char *types[] = {"CALLS"}; - if (cbm_zova_graph_session_degree(s->zova_graph_session, - s->zova_graph_workspace_id, graph, stable, - "inbound", types, 1, in_deg) != 0 || - cbm_zova_graph_session_degree(s->zova_graph_session, - s->zova_graph_workspace_id, graph, stable, - "outbound", types, 1, out_deg) != 0) { - *in_deg = 0; - *out_deg = 0; - return; - } - s->zova_native_topology_ops += 2; - return; - } - - const char *in_sql = "SELECT COUNT(*) FROM edges WHERE target_id = ?1 AND type = 'CALLS'"; - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, in_sql, CBM_NOT_FOUND, &stmt, NULL) == SQLITE_OK) { - sqlite3_bind_int64(stmt, SKIP_ONE, node_id); - if (sqlite3_step(stmt) == SQLITE_ROW) { - *in_deg = sqlite3_column_int(stmt, 0); - } - sqlite3_finalize(stmt); - } - - const char *out_sql = "SELECT COUNT(*) FROM edges WHERE source_id = ?1 AND type = 'CALLS'"; - if (sqlite3_prepare_v2(s->db, out_sql, CBM_NOT_FOUND, &stmt, NULL) == SQLITE_OK) { - sqlite3_bind_int64(stmt, SKIP_ONE, node_id); - if (sqlite3_step(stmt) == SQLITE_ROW) { - *out_deg = sqlite3_column_int(stmt, 0); - } - sqlite3_finalize(stmt); - } -} - -/* ── List distinct file paths ────────────────────────────────── */ - -int cbm_store_list_files(cbm_store_t *s, const char *project, char ***out, int *count) { - *out = NULL; - *count = 0; - if (!s || !s->db || !project) { - return CBM_STORE_ERR; - } - if (store_legacy_scope(s, &project) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - - /* Upstream cc9c2f4c: a node path can name a graph-only identity (a Folder, - * , another synthetic source). Indexed stores have one - * canonical File node per scannable path, so prefer that exact set; stores - * without File nodes keep the node-path fallback minus Folder directories. */ - const char *sql = "SELECT DISTINCT file_path FROM nodes " - "WHERE project = ?1 AND label != 'Folder' " - "AND file_path IS NOT NULL AND file_path != '' " - "AND (label = 'File' OR NOT EXISTS (" - "SELECT 1 FROM nodes WHERE project = ?1 AND label = 'File'))"; - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - return CBM_STORE_ERR; - } - sqlite3_bind_text(stmt, SKIP_ONE, project, CBM_NOT_FOUND, SQLITE_STATIC); - - int cap = CBM_SZ_64; - int n = 0; - char **files = malloc(cap * sizeof(char *)); - int scan_rc9; - while ((scan_rc9 = sqlite3_step(stmt)) == SQLITE_ROW) { - const char *fp = (const char *)sqlite3_column_text(stmt, 0); - if (!fp) { - continue; - } - if (n >= cap) { - cap *= ST_GROWTH; - files = safe_realloc(files, cap * sizeof(char *)); - } - files[n++] = cbm_heap_strdup(fp); - } - if (scan_rc9 != SQLITE_DONE) { /* SCANCHK:9:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - for (int fi = 0; fi < n; fi++) { - free(files[fi]); - } - free(files); - *out = NULL; - *count = 0; - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - *out = files; - *count = n; - return CBM_STORE_OK; -} - -/* ── Node neighbor names ──────────────────────────────────────── */ - -static int query_neighbor_names(sqlite3 *db, const char *sql, int64_t node_id, int limit, - char ***out, int *out_count) { - *out = NULL; - *out_count = 0; - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - return CBM_NOT_FOUND; - } - sqlite3_bind_int64(stmt, SKIP_ONE, node_id); - sqlite3_bind_int(stmt, ST_COL_2, limit); - - int cap = ST_INIT_CAP_8; - char **names = malloc((size_t)cap * sizeof(char *)); - int count = 0; - int scan_rc10; - while ((scan_rc10 = sqlite3_step(stmt)) == SQLITE_ROW) { - const char *name = (const char *)sqlite3_column_text(stmt, 0); - if (!name) { - continue; - } - if (count >= cap) { - cap *= ST_GROWTH; - names = safe_realloc(names, (size_t)cap * sizeof(char *)); - } - names[count++] = strdup(name); - } - if (scan_rc10 != SQLITE_DONE) { /* SCANCHK:10:stmt */ - /* No store handle here; neighbor names are include_connected - * enrichment — fail the call, primary surfaces report loudly. */ - sqlite3_finalize(stmt); - for (int ni = 0; ni < count; ni++) { - free(names[ni]); - } - free(names); - *out = NULL; - *out_count = 0; - return CBM_NOT_FOUND; - } - sqlite3_finalize(stmt); - *out = names; - *out_count = count; - return 0; -} - -static int store_name_compare(const void *left, const void *right) { - return strcmp(*(char *const *)left, *(char *const *)right); -} - -/* Distinct, sorted names of the call neighbors of a Zova node, from the graph's - * own adjacency index and the workspace nodes view. */ -static int store_zova_neighbor_names(cbm_store_t *s, const char *stable_id, bool inbound, - int limit, char ***out, int *out_count) { - int64_t *keys = NULL; - int key_count = 0; - if (cbm_zova_repository_call_neighbor_keys(s->zova_repository, s->zova_graph_workspace_id, - stable_id, inbound, &keys, - &key_count) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - sqlite3_stmt *stmt = NULL; - if (key_count == 0 || sqlite3_prepare_v2(s->db, "SELECT name FROM nodes WHERE id = ?1", - CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - free(keys); - return key_count == 0 ? CBM_STORE_OK : CBM_STORE_ERR; - } - char **names = malloc((size_t)key_count * sizeof(char *)); - int count = 0; - for (int i = 0; names && i < key_count; i++) { - sqlite3_reset(stmt); - sqlite3_bind_int64(stmt, SKIP_ONE, keys[i]); - const char *name = - sqlite3_step(stmt) == SQLITE_ROW ? (const char *)sqlite3_column_text(stmt, 0) : NULL; - if (name) { - names[count++] = cbm_heap_strdup(name); - } - } - sqlite3_finalize(stmt); - free(keys); - if (!names) { - return CBM_STORE_ERR; - } - qsort(names, (size_t)count, sizeof(char *), store_name_compare); - int kept = 0; - for (int i = 0; i < count; i++) { - if (kept < limit && (kept == 0 || strcmp(names[kept - 1], names[i]) != 0)) { - names[kept++] = names[i]; - } else { - free(names[i]); - } - } - *out = names; - *out_count = kept; - return CBM_STORE_OK; -} - -int cbm_store_node_neighbor_names(cbm_store_t *s, int64_t node_id, int limit, char ***out_callers, - int *caller_count, char ***out_callees, int *callee_count) { - if (!s) { - return CBM_NOT_FOUND; - } - *out_callers = NULL; - *caller_count = 0; - *out_callees = NULL; - *callee_count = 0; - - if (s->zova_workspace_query && s->zova_repository) { - const char *project = NULL; - char stable[CBM_ZOVA_WORKSPACE_ID_MAX + 96] = {0}; - if (store_legacy_scope(s, &project) != CBM_STORE_OK || - cbm_zova_repository_stable_id_for_numeric_id(s->zova_repository, - s->zova_graph_workspace_id, node_id, - stable, sizeof(stable)) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - if (store_zova_neighbor_names(s, stable, true, limit, out_callers, caller_count) != - CBM_STORE_OK || - store_zova_neighbor_names(s, stable, false, limit, out_callees, callee_count) != - CBM_STORE_OK) { - return CBM_STORE_ERR; - } - return 0; - } - - query_neighbor_names( - s->db, - "SELECT DISTINCT n.name FROM edges e JOIN nodes n ON e.source_id = n.id " - "WHERE e.target_id = ?1 AND e.type IN ('CALLS','HTTP_CALLS','ASYNC_CALLS') " - "ORDER BY n.name LIMIT ?2", - node_id, limit, out_callers, caller_count); - - query_neighbor_names( - s->db, - "SELECT DISTINCT n.name FROM edges e JOIN nodes n ON e.target_id = n.id " - "WHERE e.source_id = ?1 AND e.type IN ('CALLS','HTTP_CALLS','ASYNC_CALLS') " - "ORDER BY n.name LIMIT ?2", - node_id, limit, out_callees, callee_count); - - return 0; -} - -static int count_degrees_direction(cbm_store_t *s, const int64_t *node_ids, int id_count, - const char *in_clause, bool has_type, const char *edge_type, - bool inbound, int *out_counts) { - char sql[ST_SQL_BUF]; - const char *id_col = inbound ? "target_id" : "source_id"; - if (has_type) { - snprintf(sql, sizeof(sql), - "SELECT %s, COUNT(*) FROM edges " - "WHERE %s IN (%s) AND type = ? GROUP BY %s", - id_col, id_col, in_clause, id_col); - } else { - snprintf(sql, sizeof(sql), - "SELECT %s, COUNT(*) FROM edges " - "WHERE %s IN (%s) GROUP BY %s", - id_col, id_col, in_clause, id_col); - } - - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - return CBM_STORE_ERR; - } - - for (int i = 0; i < id_count; i++) { - sqlite3_bind_int64(stmt, i + SKIP_ONE, node_ids[i]); - } - if (has_type) { - bind_text(stmt, id_count + SKIP_ONE, edge_type); - } - - int scan_rc11; - while ((scan_rc11 = sqlite3_step(stmt)) == SQLITE_ROW) { - int64_t nid = sqlite3_column_int64(stmt, 0); - int cnt = sqlite3_column_int(stmt, SKIP_ONE); - for (int i = 0; i < id_count; i++) { - if (node_ids[i] == nid) { - out_counts[i] = cnt; - break; - } - } - } - if (scan_rc11 != SQLITE_DONE) { /* SCANCHK:11:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - return CBM_STORE_OK; -} - -int cbm_store_batch_count_degrees(cbm_store_t *s, const int64_t *node_ids, int id_count, - const char *edge_type, int *out_in, int *out_out) { - if (!s || !node_ids || id_count <= 0 || !out_in || !out_out) { - return CBM_STORE_ERR; - } - - memset(out_in, 0, (size_t)id_count * sizeof(int)); - memset(out_out, 0, (size_t)id_count * sizeof(int)); - - if (s->zova_workspace_query) { - for (int i = 0; i < id_count; ++i) { - if (!edge_type || strcmp(edge_type, "CALLS") == 0) { - cbm_store_node_degree(s, node_ids[i], &out_in[i], &out_out[i]); - continue; - } - cbm_edge_t *edges = NULL; - int count = 0; - if (store_zova_workspace_edges(s, node_ids[i], "inbound", edge_type, - &edges, &count) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - out_in[i] = count; - cbm_store_free_edges(edges, count); - edges = NULL; - count = 0; - if (store_zova_workspace_edges(s, node_ids[i], "outbound", edge_type, - &edges, &count) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - out_out[i] = count; - cbm_store_free_edges(edges, count); - } - return CBM_STORE_OK; - } - - /* Build IN clause: (?,?,?) */ - char in_clause[CBM_SZ_4K]; - int pos = 0; - for (int i = 0; i < id_count && pos < (int)sizeof(in_clause) - ST_IN_CLAUSE_MARGIN; i++) { - if (i > 0) { - in_clause[pos++] = ','; - } - in_clause[pos++] = '?'; - } - in_clause[pos] = '\0'; - - bool has_type = edge_type && edge_type[0] != '\0'; - - int rc = count_degrees_direction(s, node_ids, id_count, in_clause, has_type, edge_type, true, - out_in); - if (rc != CBM_STORE_OK) { - return rc; - } - - return count_degrees_direction(s, node_ids, id_count, in_clause, has_type, edge_type, false, - out_out); -} - -/* ── UpsertFileHashBatch ───────────────────────────────────────── */ - -int cbm_store_upsert_file_hash_batch(cbm_store_t *s, const cbm_file_hash_t *hashes, int count) { - if (count == 0) { - return CBM_STORE_OK; - } - - int rc = cbm_store_begin(s); - if (rc != CBM_STORE_OK) { - return rc; - } - - for (int i = 0; i < count; i++) { - rc = cbm_store_upsert_file_hash(s, hashes[i].project, hashes[i].rel_path, hashes[i].sha256, - hashes[i].mtime_ns, hashes[i].size); - if (rc != CBM_STORE_OK) { - cbm_store_rollback(s); - return rc; - } - } - - return cbm_store_commit(s); -} - -/* ── FindEdgesByURLPath ────────────────────────────────────────── */ - -int cbm_store_find_edges_by_url_path(cbm_store_t *s, const char *project, const char *keyword, - cbm_edge_t **out, int *count) { - *out = NULL; - *count = 0; - - /* Search properties JSON for url_path containing keyword */ - char like_pattern[CBM_SZ_512]; - snprintf(like_pattern, sizeof(like_pattern), "%%\"url_path\":\"%%%s%%\"%%", keyword); - - const char *sql = "SELECT id, project, source_id, target_id, type, properties FROM edges " - "WHERE project = ?1 AND properties LIKE ?2"; - - sqlite3_stmt *stmt = NULL; - int rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL); - if (rc != SQLITE_OK) { - store_set_error_sqlite(s, "url_path prepare"); - return CBM_STORE_ERR; - } - - bind_text(stmt, SKIP_ONE, project); - bind_text(stmt, ST_COL_2, like_pattern); - - int cap = ST_INIT_CAP_8; - int n = 0; - cbm_edge_t *edges = malloc(cap * sizeof(cbm_edge_t)); - int scan_rc12; - while ((scan_rc12 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - edges = safe_realloc(edges, cap * sizeof(cbm_edge_t)); - } - memset(&edges[n], 0, sizeof(cbm_edge_t)); - scan_edge(stmt, &edges[n]); - n++; - } - if (scan_rc12 != SQLITE_DONE) { /* SCANCHK:12:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - cbm_store_free_edges(edges, n); - *out = NULL; - *count = 0; - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - *out = edges; - *count = n; - return CBM_STORE_OK; -} - -/* ── RestoreFrom ───────────────────────────────────────────────── */ - -int cbm_store_restore_from(cbm_store_t *dst, cbm_store_t *src) { - if (!dst || !src) { - return CBM_STORE_ERR; - } - sqlite3_backup *bk = sqlite3_backup_init(dst->db, "main", src->db, "main"); - if (!bk) { - store_set_error_sqlite(dst, "backup init"); - return CBM_STORE_ERR; - } - int rc = sqlite3_backup_step(bk, CBM_NOT_FOUND); /* copy all pages */ - sqlite3_backup_finish(bk); - - if (rc != SQLITE_DONE) { - store_set_error(dst, "backup step failed"); - return CBM_STORE_ERR; - } - return CBM_STORE_OK; -} - -/* ── Search ─────────────────────────────────────────────────────── */ - -/* Convert a glob pattern to SQL LIKE pattern. */ -char *cbm_glob_to_like(const char *pattern) { - if (!pattern) { - return NULL; - } - size_t len = strlen(pattern); - char *out = malloc((len * ST_GROWTH) + SKIP_ONE); - size_t j = 0; - - for (size_t i = 0; i < len; i++) { - if (pattern[i] == '*' && i + SKIP_ONE < len && pattern[i + SKIP_ONE] == '*') { - /* Remove leading / from output if present (handles glob dir-star) */ - if (j > 0 && out[j - SKIP_ONE] == '/') { - j--; - } - out[j++] = '%'; - i++; /* skip second * */ - if (i + SKIP_ONE < len && pattern[i + SKIP_ONE] == '/') { - i++; /* skip trailing / */ - } - } else if (pattern[i] == '*') { - out[j++] = '%'; - } else if (pattern[i] == '?') { - out[j++] = '_'; - } else { - out[j++] = pattern[i]; - } - } - out[j] = '\0'; - return out; -} - -/* ── extractLikeHints ─────────────────────────────────────────── */ - -int cbm_extract_like_hints(const char *pattern, char **out, int max_out) { - if (!pattern || !out || max_out <= 0) { - return 0; - } - - /* Bail on alternation — can't convert OR regex to AND LIKE */ - for (const char *p = pattern; *p; p++) { - if (*p == '|') { - return 0; - } - } - - int count = 0; - char buf[CBM_SZ_256]; - int blen = 0; - - int i = 0; - while (pattern[i]) { - char ch = pattern[i]; - switch (ch) { - case '\\': - /* Escaped char — the next char is literal */ - if (pattern[i + SKIP_ONE]) { - if (blen < (int)sizeof(buf) - SKIP_ONE) { - buf[blen++] = pattern[i + SKIP_ONE]; - } - i += ST_GLOB_SKIP; - } else { - i++; - } - break; - case '.': - case '*': - case '+': - case '?': - case '^': - case '$': - case '(': - case ')': - case '[': - case ']': - case '{': - case '}': - /* Meta character — flush current literal segment */ - if (blen >= ST_GLOB_MIN_LEN && count < max_out) { - buf[blen] = '\0'; - out[count++] = strdup(buf); - } - blen = 0; - i++; - break; - default: - if (blen < (int)sizeof(buf) - SKIP_ONE) { - buf[blen++] = ch; - } - i++; - break; - } - } - /* Flush trailing segment */ - if (blen >= ST_GLOB_MIN_LEN && count < max_out) { - buf[blen] = '\0'; - out[count++] = strdup(buf); - } - return count; -} - -/* ── ensureCaseInsensitive / stripCaseFlag ────────────────────── */ - -const char *cbm_ensure_case_insensitive(const char *pattern) { - static char buf[CBM_SZ_2K]; - if (!pattern) { - buf[0] = '\0'; - return buf; - } - /* Already has (?i) prefix? Return as-is. */ - if (strncmp(pattern, "(?i)", SLEN("(?i)")) == 0) { - snprintf(buf, sizeof(buf), "%s", pattern); - } else { - snprintf(buf, sizeof(buf), "(?i)%s", pattern); - } - return buf; -} - -const char *cbm_strip_case_flag(const char *pattern) { - static char buf[CBM_SZ_2K]; - if (!pattern) { - buf[0] = '\0'; - return buf; - } - if (strncmp(pattern, "(?i)", SLEN("(?i)")) == 0) { - snprintf(buf, sizeof(buf), "%s", pattern + 4); - } else { - snprintf(buf, sizeof(buf), "%s", pattern); - } - return buf; -} - -/* Bind value tracker for dynamic query building */ -typedef struct { - const char *text; -} search_bind_t; - -/* Pool of malloc'd strings that must outlive statement execution. - * Freed in one shot after both statements are finalized. */ -typedef struct { - char *ptrs[ST_LIKE_POOL_MAX]; - int count; -} search_like_pool_t; - -static void like_pool_add(search_like_pool_t *pool, char *ptr) { - if (ptr && pool->count < ST_LIKE_POOL_MAX) { - pool->ptrs[pool->count++] = ptr; - } else { - free(ptr); /* pool full — don't leak */ - } -} - -static void like_pool_free(search_like_pool_t *pool) { - for (int i = 0; i < pool->count; i++) { - free(pool->ptrs[i]); - } - pool->count = 0; -} - -/* Wrap a literal string in % for use as a LIKE pattern (%literal%). */ -static char *make_like_hint(const char *literal) { - size_t len = strlen(literal); - char *buf = malloc(len + 3); /* % + literal + % + NUL */ - if (buf) { - buf[0] = '%'; - memcpy(buf + SKIP_ONE, literal, len); - buf[len + SKIP_ONE] = '%'; - buf[len + 2] = '\0'; - } - return buf; -} - -static void search_apply_degree_filter(char *sql, size_t sql_sz, const cbm_search_params_t *p) { - bool has_degree_filter = (p->min_degree >= 0 || p->max_degree >= 0); - if (!has_degree_filter) { - return; - } - char inner_sql[CBM_SZ_4K]; - snprintf(inner_sql, sizeof(inner_sql), "%s", sql); - if (p->min_degree >= 0 && p->max_degree >= 0) { - snprintf(sql, sql_sz, - "SELECT * FROM (%s) WHERE (in_deg + out_deg) >= %d AND (in_deg + out_deg) <= %d", - inner_sql, p->min_degree, p->max_degree); - } else if (p->min_degree >= 0) { - snprintf(sql, sql_sz, "SELECT * FROM (%s) WHERE (in_deg + out_deg) >= %d", inner_sql, - p->min_degree); - } else { - snprintf(sql, sql_sz, "SELECT * FROM (%s) WHERE (in_deg + out_deg) <= %d", inner_sql, - p->max_degree); - } -} - -/* Append a WHERE clause fragment, joining with AND if not the first. */ -static int where_append(char *where, int where_sz, int wlen, int *nparams, const char *cond) { - if (*nparams > 0) { - wlen += snprintf(where + wlen, where_sz - wlen, " AND "); - } - wlen += snprintf(where + wlen, where_sz - wlen, "%s", cond); - (*nparams)++; - return wlen; -} - -/* Bind a text parameter and increment the bind index. */ -static void where_bind_text(search_bind_t *binds, int *bind_idx, const char *val) { - binds[*bind_idx].text = val; - (*bind_idx)++; -} - -/* Build exclude-labels NOT IN clause with bind placeholders. */ -static void search_build_exclude_labels(const char **labels, search_bind_t *binds, int *bind_idx, - char *clause, int clause_sz) { - int elen = snprintf(clause, clause_sz, "n.label NOT IN ("); - for (int i = 0; labels[i]; i++) { - if (i > 0) { - elen += snprintf(clause + elen, clause_sz - elen, ","); - if (elen >= clause_sz) { - elen = clause_sz - SKIP_ONE; - } - } - elen += snprintf(clause + elen, clause_sz - elen, "?%d", *bind_idx + SKIP_ONE); - if (elen >= clause_sz) { - elen = clause_sz - SKIP_ONE; - } - where_bind_text(binds, bind_idx, labels[i]); - } - snprintf(clause + elen, clause_sz - elen, ")"); -} - -/* Append a regex WHERE clause for a column (case-sensitive or insensitive). */ -static void where_add_regex(char *where, int where_sz, int *wlen, int *nparams, - search_bind_t *binds, int *bind_idx, const char *column, - const char *pattern, bool case_sensitive) { - char buf[CBM_SZ_128]; - if (case_sensitive) { - snprintf(buf, sizeof(buf), "%s REGEXP ?%d", column, *bind_idx + SKIP_ONE); - } else { - snprintf(buf, sizeof(buf), "iregexp(?%d, %s)", *bind_idx + SKIP_ONE, column); - } - *wlen = where_append(where, where_sz, *wlen, nparams, buf); - where_bind_text(binds, bind_idx, pattern); -} - -/* Prepend LIKE pre-filter conditions for literal segments of a regex pattern. - * The idx_nodes_name index satisfies LIKE '%literal%', cutting the rows that - * reach the (more expensive) iregexp call to only those containing the literal. - * cbm_extract_like_hints bails on alternation, so no false negatives. */ -static void where_add_like_hints(const char *column, const char *pattern, char *where, int where_sz, - int *wlen, int *nparams, search_bind_t *binds, int *bind_idx, - search_like_pool_t *pool) { - char *hints[ST_LIKE_HINT_MAX]; - int nhints = cbm_extract_like_hints(pattern, hints, ST_LIKE_HINT_MAX); - char bind_buf[CBM_SZ_64]; - for (int i = 0; i < nhints; i++) { - char *lp = make_like_hint(hints[i]); - free(hints[i]); - if (!lp) - continue; - int pool_was_full = (pool->count >= ST_LIKE_POOL_MAX); - like_pool_add(pool, lp); - if (pool_was_full) - continue; /* lp was freed — skip bind */ - snprintf(bind_buf, sizeof(bind_buf), "%s LIKE ?%d", column, *bind_idx + SKIP_ONE); - *wlen = where_append(where, where_sz, *wlen, nparams, bind_buf); - where_bind_text(binds, bind_idx, lp); - } -} - -/* Build basic WHERE clauses: project, label, name, file, qn patterns. */ -static int search_where_basic(const cbm_search_params_t *params, char *where, int where_sz, - int *wlen, int *nparams, search_bind_t *binds, int *bind_idx, - search_like_pool_t *pool) { - char bind_buf[CBM_SZ_64]; - - if (params->project) { - snprintf(bind_buf, sizeof(bind_buf), "n.project = ?%d", *bind_idx + SKIP_ONE); - *wlen = where_append(where, where_sz, *wlen, nparams, bind_buf); - where_bind_text(binds, bind_idx, params->project); - } - /* Ignore an empty-string label: it is non-NULL but should behave like an - * omitted label (no filter), matching the BM25 query path. Without the - * params->label[0] guard, name_pattern/qn_pattern searches that pass - * label="" append `n.label = ''`, which matches no node and silently - * returns zero results (issue #481). */ - if (params->label && params->label[0]) { - snprintf(bind_buf, sizeof(bind_buf), "n.label = ?%d", *bind_idx + SKIP_ONE); - *wlen = where_append(where, where_sz, *wlen, nparams, bind_buf); - where_bind_text(binds, bind_idx, params->label); - } - if (params->name_pattern) { - where_add_like_hints("n.name", params->name_pattern, where, where_sz, wlen, nparams, binds, - bind_idx, pool); - where_add_regex(where, where_sz, wlen, nparams, binds, bind_idx, "n.name", - params->name_pattern, params->case_sensitive); - } - if (params->qn_pattern) { - where_add_like_hints("n.qualified_name", params->qn_pattern, where, where_sz, wlen, nparams, - binds, bind_idx, pool); - where_add_regex(where, where_sz, wlen, nparams, binds, bind_idx, "n.qualified_name", - params->qn_pattern, params->case_sensitive); - } - if (params->file_pattern) { - char *lp = cbm_glob_to_like(params->file_pattern); - /* A file_pattern with no glob wildcards is treated as a path-substring - * match (issue #200): file_pattern="offer-server" should match - * "src/offer-server/x.js", not only a path equal to "offer-server". - * Patterns that DO contain * / ? keep their explicit glob semantics. */ - if (lp && !strchr(params->file_pattern, '*') && !strchr(params->file_pattern, '?')) { - size_t lplen = strlen(lp); - char *contains = malloc(lplen + 3); - if (contains) { - contains[0] = '%'; - memcpy(contains + 1, lp, lplen); - contains[lplen + 1] = '%'; - contains[lplen + 2] = '\0'; - free(lp); - lp = contains; - } - } - int pool_was_full = (pool->count >= ST_LIKE_POOL_MAX); - like_pool_add(pool, lp); - if (!pool_was_full && lp) { - snprintf(bind_buf, sizeof(bind_buf), "n.file_path LIKE ?%d", *bind_idx + SKIP_ONE); - *wlen = where_append(where, where_sz, *wlen, nparams, bind_buf); - where_bind_text(binds, bind_idx, lp); - } - } - return *nparams; -} - -/* Build advanced WHERE clauses: relationship, entry points, exclude labels. */ -static void search_where_advanced(const cbm_search_params_t *params, char *where, int where_sz, - int *wlen, int *nparams, search_bind_t *binds, int *bind_idx) { - if (params->relationship) { - char rel_clause[CBM_SZ_256]; - snprintf(rel_clause, sizeof(rel_clause), - "EXISTS(SELECT 1 FROM edges e WHERE " - "(e.source_id = n.id OR e.target_id = n.id) AND e.type = ?%d)", - *bind_idx + SKIP_ONE); - *wlen = where_append(where, where_sz, *wlen, nparams, rel_clause); - where_bind_text(binds, bind_idx, params->relationship); - } - if (params->exclude_entry_points) { - /* Exclude nodes with no inbound CALLS but at least one outbound CALLS. - * Dead code (degree=0) is NOT excluded — only true entry points. */ - *wlen = where_append(where, where_sz, *wlen, nparams, - "NOT (NOT EXISTS(SELECT 1 FROM edges e WHERE e.target_id = n.id " - "AND e.type = 'CALLS') " - "AND EXISTS(SELECT 1 FROM edges e2 WHERE e2.source_id = n.id " - "AND e2.type = 'CALLS'))"); - } - if (params->exclude_labels) { - char excl_clause[CBM_SZ_512]; - search_build_exclude_labels(params->exclude_labels, binds, bind_idx, excl_clause, - (int)sizeof(excl_clause)); - (void)where_append(where, where_sz, *wlen, nparams, excl_clause); - } -} - -static int search_build_where(const cbm_search_params_t *params, char *where, int where_sz, - search_bind_t *binds, int *bind_idx, search_like_pool_t *pool) { - int wlen = 0; - int nparams = 0; - - search_where_basic(params, where, where_sz, &wlen, &nparams, binds, bind_idx, pool); - search_where_advanced(params, where, where_sz, &wlen, &nparams, binds, bind_idx); - - return nparams; -} - -int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_search_output_t *out) { - memset(out, 0, sizeof(*out)); - if (s && s->backend) { - return s->backend->search(s->backend_ctx, params, out); - } - if (!s || !s->db) { - return CBM_STORE_ERR; - } - if (s->zova_workspace_query && s->zova_repository) { - int rc = cbm_zova_repository_search( - s->zova_repository, s->zova_graph_workspace_id, params, out); - if (rc == CBM_STORE_OK) s->zova_native_topology_ops += 8; - return rc; - } - - char sql[CBM_SZ_4K]; - char count_sql[CBM_SZ_4K]; - int bind_idx = 0; - - const char *select_cols = "SELECT n.id, n.project, n.label, n.name, n.qualified_name, " - "n.file_path, n.start_line, n.end_line, n.properties, " - "(SELECT COUNT(*) FROM edges e WHERE e.target_id = n.id AND " - "e.type IN ('CALLS', 'USAGE', 'CALL_REFERENCE', 'INHERITS', 'IMPLEMENTS')) " - "AS in_deg, " - "(SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id AND " - "e.type IN ('CALLS', 'USAGE', 'CALL_REFERENCE', 'INHERITS', 'IMPLEMENTS')) " - "AS out_deg "; - - char where[CBM_SZ_2K] = ""; - search_bind_t binds[ST_SEARCH_MAX_BINDS]; - search_like_pool_t like_pool = {0}; - - int nparams = - search_build_where(params, where, (int)sizeof(where), binds, &bind_idx, &like_pool); - - /* Build full SQL */ - if (nparams > 0) { - snprintf(sql, sizeof(sql), "%s FROM nodes n WHERE %s", select_cols, where); - } else { - snprintf(sql, sizeof(sql), "%s FROM nodes n", select_cols); - } - - /* Degree filters */ - bool has_degree_filter = (params->min_degree >= 0 || params->max_degree >= 0); - search_apply_degree_filter(sql, sizeof(sql), params); - - /* Count query — stripped of per-row edge subqueries for the common (no-degree-filter) - * case, since we only need the row count, not in_deg/out_deg. The degree-filter - * case must wrap the full query because the filter references those columns. */ - if (has_degree_filter) { - snprintf(count_sql, sizeof(count_sql), "SELECT COUNT(*) FROM (%s)", sql); - } else if (nparams > 0) { - snprintf(count_sql, sizeof(count_sql), "SELECT COUNT(*) FROM nodes n WHERE %s", where); - } else { - snprintf(count_sql, sizeof(count_sql), "SELECT COUNT(*) FROM nodes n"); - } - - /* Add ORDER BY + LIMIT */ - int limit = params->limit > 0 ? params->limit : CBM_DEFAULT_SEARCH_LIMIT; - int offset = params->offset; - const char *name_col = has_degree_filter ? "name" : "n.name"; - char order_limit[CBM_SZ_128]; - snprintf(order_limit, sizeof(order_limit), - has_degree_filter - ? " ORDER BY %s, qualified_name, id LIMIT %d OFFSET %d" - : " ORDER BY %s, n.qualified_name, n.id LIMIT %d OFFSET %d", - name_col, limit, offset); - strncat(sql, order_limit, sizeof(sql) - strlen(sql) - 1); - - /* Execute count query */ - sqlite3_stmt *cnt_stmt = NULL; - int rc = sqlite3_prepare_v2(s->db, count_sql, CBM_NOT_FOUND, &cnt_stmt, NULL); - if (rc == SQLITE_OK) { - for (int i = 0; i < bind_idx; i++) { - bind_text(cnt_stmt, i + SKIP_ONE, binds[i].text); - } - if (sqlite3_step(cnt_stmt) == SQLITE_ROW) { - out->total = sqlite3_column_int(cnt_stmt, 0); - } - sqlite3_finalize(cnt_stmt); - } - - /* Execute main query */ - sqlite3_stmt *main_stmt = NULL; - rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &main_stmt, NULL); - if (rc != SQLITE_OK) { - store_set_error_sqlite(s, "search prepare"); - like_pool_free(&like_pool); - return CBM_STORE_ERR; - } - - for (int i = 0; i < bind_idx; i++) { - bind_text(main_stmt, i + SKIP_ONE, binds[i].text); - } - - int cap = ST_INIT_CAP_16; - int n = 0; - cbm_search_result_t *results = malloc(cap * sizeof(cbm_search_result_t)); - - int scan_rc13; - while ((scan_rc13 = sqlite3_step(main_stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - results = safe_realloc(results, cap * sizeof(cbm_search_result_t)); - } - memset(&results[n], 0, sizeof(cbm_search_result_t)); - scan_node(main_stmt, &results[n].node); - results[n].in_degree = sqlite3_column_int(main_stmt, ST_COL_9); - results[n].out_degree = sqlite3_column_int(main_stmt, CBM_DECIMAL_BASE); - n++; - } - if (scan_rc13 != SQLITE_DONE) { /* SCANCHK:13:main_stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(main_stmt); - like_pool_free(&like_pool); - out->results = results; - out->count = n; - return CBM_STORE_ERR; - } - - sqlite3_finalize(main_stmt); - like_pool_free(&like_pool); - - out->results = results; - out->count = n; - return CBM_STORE_OK; -} - -void cbm_store_search_free(cbm_search_output_t *out) { - if (!out) { - return; - } - for (int i = 0; i < out->count; i++) { - cbm_search_result_t *r = &out->results[i]; - safe_str_free(&r->node.project); - safe_str_free(&r->node.label); - safe_str_free(&r->node.name); - safe_str_free(&r->node.qualified_name); - safe_str_free(&r->node.file_path); - safe_str_free(&r->node.properties_json); - for (int j = 0; j < r->connected_count; j++) { - safe_str_free(&r->connected_names[j]); - } - free(r->connected_names); - } - free(out->results); - memset(out, 0, sizeof(*out)); -} - -/* ── BFS Traversal ──────────────────────────────────────────────── */ - -static int bfs_collect_edges(cbm_store_t *s, int64_t start_id, const cbm_node_hop_t *visited, - int visited_count, const char *types_clause, const char **edge_types, - int edge_type_count, cbm_edge_info_t **out_edges, - int *out_edge_count) { - /* Build ID set: root + all visited */ - char id_set[CBM_SZ_4K]; - int ilen = snprintf(id_set, sizeof(id_set), "%lld", (long long)start_id); - if (ilen >= (int)sizeof(id_set)) { - ilen = (int)sizeof(id_set) - SKIP_ONE; - } - for (int i = 0; i < visited_count; i++) { - ilen += snprintf(id_set + ilen, sizeof(id_set) - (size_t)ilen, ",%lld", - (long long)visited[i].node.id); - if (ilen >= (int)sizeof(id_set)) { - ilen = (int)sizeof(id_set) - SKIP_ONE; - } - } - - char edge_sql[ST_SQL_BUF]; - snprintf(edge_sql, sizeof(edge_sql), - "SELECT n1.name, n2.name, e.type, e.source_id, e.target_id, e.properties " - "FROM edges e " - "JOIN nodes n1 ON n1.id = e.source_id " - "JOIN nodes n2 ON n2.id = e.target_id " - "WHERE e.source_id IN (%s) AND e.target_id IN (%s) " - "AND e.type IN (%s)", - id_set, id_set, types_clause); - - sqlite3_stmt *estmt = NULL; - int rc = sqlite3_prepare_v2(s->db, edge_sql, CBM_NOT_FOUND, &estmt, NULL); - if (rc != SQLITE_OK) { - *out_edges = NULL; - *out_edge_count = 0; - return CBM_STORE_OK; - } - - if (edge_type_count > 0) { - for (int i = 0; i < edge_type_count; i++) { - bind_text(estmt, i + SKIP_ONE, edge_types[i]); - } - } else { - bind_text(estmt, SKIP_ONE, "CALLS"); - } - - int ecap = ST_INIT_CAP_8; - int en = 0; - cbm_edge_info_t *edges = malloc(ecap * sizeof(cbm_edge_info_t)); - - int scan_rc14; - while ((scan_rc14 = sqlite3_step(estmt)) == SQLITE_ROW) { - if (en >= ecap) { - ecap *= ST_GROWTH; - edges = safe_realloc(edges, ecap * sizeof(cbm_edge_info_t)); - } - edges[en].from_name = cbm_heap_strdup((const char *)sqlite3_column_text(estmt, 0)); - edges[en].to_name = cbm_heap_strdup((const char *)sqlite3_column_text(estmt, SKIP_ONE)); - edges[en].type = cbm_heap_strdup((const char *)sqlite3_column_text(estmt, CBM_SZ_2)); - edges[en].confidence = (double)SKIP_ONE; - edges[en].source_id = sqlite3_column_int64(estmt, ST_COL_3); - edges[en].target_id = sqlite3_column_int64(estmt, ST_COL_4); - edges[en].properties_json = - cbm_heap_strdup((const char *)sqlite3_column_text(estmt, CBM_SZ_5)); - en++; - } - if (scan_rc14 != SQLITE_DONE) { /* SCANCHK:14:estmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(estmt); - *out_edges = edges; - *out_edge_count = en; - return CBM_STORE_ERR; - } - sqlite3_finalize(estmt); - - *out_edges = edges; - *out_edge_count = en; - return CBM_STORE_OK; -} - -/* Build parameterized placeholder list "?1,?2,?3" for N edge types. */ -static void bfs_build_types_clause(int edge_type_count, char *buf, int buf_sz) { - if (edge_type_count <= 0) { - snprintf(buf, buf_sz, "?1"); - return; - } - int tlen = 0; - for (int i = 0; i < edge_type_count; i++) { - if (i > 0) { - tlen += snprintf(buf + tlen, buf_sz - tlen, ","); - if (tlen >= buf_sz) { - tlen = buf_sz - SKIP_ONE; - } - } - tlen += snprintf(buf + tlen, buf_sz - tlen, "?%d", i + SKIP_ONE); - if (tlen >= buf_sz) { - tlen = buf_sz - SKIP_ONE; - } - } -} - -static int store_find_cached_zova_workspace_node(cbm_store_t *s, int64_t node_numeric_id, - cbm_node_t *out) { - if (!s || !out || !s->zova_workspace_query || - !s->zova_graph_cached_stable_id_valid || - s->zova_graph_cached_numeric_id != node_numeric_id) { - return CBM_STORE_NOT_FOUND; - } - return cbm_zova_repository_find_node_by_numeric_id( - s->zova_repository, s->zova_graph_workspace_id, node_numeric_id, out); -} - -static int store_zova_workspace_bfs(cbm_store_t *s, int64_t start_id, const char *direction, - const char **edge_types, int edge_type_count, int max_depth, - int max_results, cbm_traverse_result_t *out) { - if (!s->zova_workspace_query || !s->zova_graph_session || - (edge_type_count > 1) || - (edge_type_count == 1 && strcmp(edge_types[0], "CALLS") != 0) || - (direction && strcmp(direction, "outbound") != 0 && strcmp(direction, "inbound") != 0)) - return CBM_STORE_NOT_FOUND; - char stable_copy[CBM_ZOVA_WORKSPACE_ID_MAX + 96]; - stable_copy[0] = '\0'; - if (s->zova_graph_cached_stable_id_valid && - s->zova_graph_cached_numeric_id == start_id) { - snprintf(stable_copy, sizeof(stable_copy), "%s", - s->zova_graph_cached_stable_id); - } else { - int stable_rc = cbm_zova_repository_stable_id_for_numeric_id( - s->zova_repository, s->zova_graph_workspace_id, start_id, - stable_copy, sizeof(stable_copy)); - if (stable_rc != CBM_STORE_OK) return stable_rc; - if (stable_copy[0]) { - s->zova_graph_cached_numeric_id = start_id; - snprintf(s->zova_graph_cached_stable_id, - sizeof(s->zova_graph_cached_stable_id), "%s", stable_copy); - s->zova_graph_cached_stable_id_valid = true; - } - } - if (!stable_copy[0]) return CBM_STORE_NOT_FOUND; - char graph_name[CBM_ZOVA_WORKSPACE_ID_MAX + 32]; - if (cbm_zova_workspace_graph_name(s->zova_graph_workspace_id, graph_name, - sizeof(graph_name)) != 0) return CBM_STORE_ERR; - cbm_zova_graph_visit_t *visits = NULL; - int count = 0; - cbm_zova_graph_metrics_t metrics = {0}; - if (cbm_zova_graph_session_walk_calls( - s->zova_graph_session, s->zova_graph_workspace_id, graph_name, stable_copy, - direction ? direction : "outbound", max_depth, max_results, &visits, &count, - &metrics) != 0) return CBM_STORE_ERR; - cbm_node_hop_t *items = count ? calloc((size_t)count, sizeof(*items)) : NULL; - if (count && !items) { cbm_zova_graph_visits_free(visits, count); return CBM_STORE_ERR; } - for (int i = 0; i < count; ++i) { - items[i].node.id = store_stable_numeric_id(visits[i].node_id); - items[i].node.project = cbm_heap_strdup(out->root.project ? out->root.project : ""); - items[i].node.label = visits[i].label; visits[i].label = NULL; - items[i].node.name = visits[i].name; visits[i].name = NULL; - items[i].node.qualified_name = visits[i].qualified_name; visits[i].qualified_name = NULL; - items[i].node.file_path = visits[i].file_path; visits[i].file_path = NULL; - items[i].node.start_line = visits[i].start_line; - items[i].node.end_line = visits[i].end_line; - items[i].node.properties_json = visits[i].properties; visits[i].properties = NULL; - items[i].hop = visits[i].hop; - } - cbm_zova_graph_visits_free(visits, count); - out->visited = items; - out->visited_count = count; - s->zova_native_topology_ops++; - return CBM_STORE_OK; -} - -/* The native workspace walk follows CALLS only. Every other edge-type set - * (CALL_REFERENCE, HTTP_CALLS, mixed sets) walks level by level over the - * workspace edge lookup with the SQL route's hop ordering and limit, and keeps - * the traversed edges so callers can read their properties. */ -static bool bfs_edge_type_selected(const char *type, const char **edge_types, - int edge_type_count) { - if (!type) return false; - if (edge_type_count <= 0) return strcmp(type, "CALLS") == 0; - for (int i = 0; i < edge_type_count; i++) - if (edge_types[i] && strcmp(type, edge_types[i]) == 0) return true; - return false; -} - -static int bfs_hop_index(const cbm_node_hop_t *hops, int count, int64_t id) { - for (int i = 0; i < count; i++) - if (hops[i].node.id == id) return i; - return CBM_NOT_FOUND; -} - -static int bfs_hop_order(const void *a, const void *b) { - const cbm_node_t *x = &((const cbm_node_hop_t *)a)->node; - const cbm_node_t *y = &((const cbm_node_hop_t *)b)->node; - int c = strcmp(x->file_path ? x->file_path : "", y->file_path ? y->file_path : ""); - if (!c) c = strcmp(x->qualified_name ? x->qualified_name : "", - y->qualified_name ? y->qualified_name : ""); - if (!c) c = strcmp(x->label ? x->label : "", y->label ? y->label : ""); - if (c) return c; - if (x->start_line != y->start_line) return x->start_line < y->start_line ? -1 : 1; - if (x->end_line != y->end_line) return x->end_line < y->end_line ? -1 : 1; - return x->id < y->id ? -1 : (x->id > y->id); -} - -static int store_zova_workspace_bfs_typed(cbm_store_t *s, int64_t start_id, - const char *direction, const char **edge_types, - int edge_type_count, int max_depth, int max_results, - cbm_traverse_result_t *out) { - bool inbound = direction && strcmp(direction, "inbound") == 0; - cbm_node_hop_t *hops = NULL; - cbm_edge_t *seen = NULL; - int hop_count = 0, hop_cap = 0, seen_count = 0, seen_cap = 0; - int level_start = 0, level_end = 0; /* hops[level_start, level_end) is the frontier */ - int rc = CBM_STORE_OK; - for (int depth = 0; depth < max_depth && rc == CBM_STORE_OK; depth++) { - int frontier = depth == 0 ? SKIP_ONE : level_end - level_start; - int next_start = hop_count; - for (int f = 0; f < frontier && rc == CBM_STORE_OK; f++) { - int64_t from = depth == 0 ? start_id : hops[level_start + f].node.id; - cbm_edge_t *edges = NULL; - int edge_count = 0; - rc = store_zova_workspace_edges(s, from, inbound ? "inbound" : "outbound", NULL, - &edges, &edge_count); - for (int e = 0; e < edge_count && rc == CBM_STORE_OK; e++) { - if (!bfs_edge_type_selected(edges[e].type, edge_types, edge_type_count)) - continue; - int64_t next = inbound ? edges[e].source_id : edges[e].target_id; - if (seen_count == seen_cap) { - seen_cap = seen_cap ? seen_cap * ST_GROWTH : ST_INIT_CAP_16; - seen = safe_realloc(seen, (size_t)seen_cap * sizeof(*seen)); - } - seen[seen_count++] = edges[e]; - memset(&edges[e], 0, sizeof(edges[e])); - if (next == start_id || bfs_hop_index(hops, hop_count, next) >= 0) continue; - if (hop_count == hop_cap) { - hop_cap = hop_cap ? hop_cap * ST_GROWTH : ST_INIT_CAP_16; - hops = safe_realloc(hops, (size_t)hop_cap * sizeof(*hops)); - } - memset(&hops[hop_count], 0, sizeof(hops[hop_count])); - rc = cbm_store_find_node_by_id(s, next, &hops[hop_count].node); - if (rc == CBM_STORE_OK) hops[hop_count++].hop = depth + SKIP_ONE; - else if (rc == CBM_STORE_NOT_FOUND) rc = CBM_STORE_OK; - } - cbm_store_free_edges(edges, edge_count); - } - if (hop_count - next_start > 1) /* hops stays NULL until the first hop */ - qsort(hops + next_start, (size_t)(hop_count - next_start), sizeof(*hops), - bfs_hop_order); - while (hop_count > max_results) cbm_node_free_fields(&hops[--hop_count].node); - level_start = next_start; - level_end = hop_count; - if (level_start == level_end) break; - } - /* Keep the edges whose endpoints both survived, as the SQL route does. */ - cbm_edge_info_t *infos = seen_count ? calloc((size_t)seen_count, sizeof(*infos)) : NULL; - int info_count = 0; - for (int e = 0; e < seen_count && infos; e++) { - int si = bfs_hop_index(hops, hop_count, seen[e].source_id); - int ti = bfs_hop_index(hops, hop_count, seen[e].target_id); - if ((si < 0 && seen[e].source_id != start_id) || - (ti < 0 && seen[e].target_id != start_id)) continue; - cbm_edge_info_t *info = &infos[info_count++]; - info->from_name = cbm_heap_strdup(si < 0 ? out->root.name : hops[si].node.name); - info->to_name = cbm_heap_strdup(ti < 0 ? out->root.name : hops[ti].node.name); - info->type = seen[e].type; - seen[e].type = NULL; - info->confidence = (double)SKIP_ONE; - info->source_id = seen[e].source_id; - info->target_id = seen[e].target_id; - info->properties_json = seen[e].properties_json; - seen[e].properties_json = NULL; - } - cbm_store_free_edges(seen, seen_count); - out->visited = hops; - out->visited_count = hop_count; - out->edges = infos; - out->edge_count = info_count; - return rc; -} - -static int store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, - const char **edge_types, int edge_type_count, int max_depth, - int max_results, bool need_edges, cbm_traverse_result_t *out) { - memset(out, 0, sizeof(*out)); - if (s && s->backend) { - return s->backend->bfs(s->backend_ctx, start_id, direction, edge_types, edge_type_count, - max_depth, max_results, out); - } - - cbm_node_t root = {0}; - int rc = store_find_cached_zova_workspace_node(s, start_id, &root); - if (rc == CBM_STORE_NOT_FOUND) { - rc = cbm_store_find_node_by_id(s, start_id, &root); - } - if (rc != CBM_STORE_OK) { - return rc; - } - out->root = root; - - /* The native walk returns nodes only; a caller that reads edge - * properties takes the typed walk instead. */ - rc = need_edges ? CBM_STORE_NOT_FOUND - : store_zova_workspace_bfs(s, start_id, direction, edge_types, - edge_type_count, max_depth, max_results, out); - if (rc == CBM_STORE_OK) return rc; - if (s->zova_workspace_query && rc == CBM_STORE_NOT_FOUND) - return store_zova_workspace_bfs_typed(s, start_id, direction, edge_types, - edge_type_count, max_depth, max_results, out); - if (s->zova_workspace_query) return rc; - - char types_clause[CBM_SZ_512]; - bfs_build_types_clause(edge_type_count, types_clause, (int)sizeof(types_clause)); - - /* Build recursive CTE for BFS */ - char sql[CBM_SZ_4K]; - const char *join_cond; - const char *next_id; - bool is_inbound = (direction != NULL) && (strcmp(direction, "inbound") == 0); - - if (is_inbound) { - join_cond = "e.target_id = bfs.node_id"; - next_id = "e.source_id"; - } else { - join_cond = "e.source_id = bfs.node_id"; - next_id = "e.target_id"; - } - - snprintf(sql, sizeof(sql), - "WITH RECURSIVE bfs(node_id, hop) AS (" - " SELECT %lld, 0" - " UNION" - " SELECT %s, bfs.hop + 1" - " FROM bfs" - " JOIN edges e ON %s" - " WHERE e.type IN (%s) AND bfs.hop < %d" - "), shortest AS (" - " SELECT node_id, MIN(hop) AS hop FROM bfs " - " WHERE hop > 0 AND node_id != %lld GROUP BY node_id" - ")" - "SELECT DISTINCT n.id, n.project, n.label, n.name, n.qualified_name, " - "n.file_path, n.start_line, n.end_line, n.properties, shortest.hop " - "FROM shortest " - "JOIN nodes n ON n.id = shortest.node_id " - "ORDER BY shortest.hop, n.file_path, n.qualified_name, n.label, n.start_line, " - "n.end_line, n.id " - "LIMIT %d;", - (long long)start_id, next_id, join_cond, types_clause, max_depth, - (long long)start_id, max_results); - - sqlite3_stmt *stmt = NULL; - rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL); - if (rc != SQLITE_OK) { - store_set_error_sqlite(s, "bfs prepare"); - return CBM_STORE_ERR; - } - - /* Bind edge type parameters */ - if (edge_type_count > 0) { - for (int i = 0; i < edge_type_count; i++) { - bind_text(stmt, i + SKIP_ONE, edge_types[i]); - } - } else { - bind_text(stmt, SKIP_ONE, "CALLS"); - } - - int cap = ST_INIT_CAP_16; - int n = 0; - cbm_node_hop_t *visited = malloc(cap * sizeof(cbm_node_hop_t)); - - int scan_rc15; - while ((scan_rc15 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - visited = safe_realloc(visited, cap * sizeof(cbm_node_hop_t)); - } - scan_node(stmt, &visited[n].node); - visited[n].hop = sqlite3_column_int(stmt, ST_COL_9); - n++; - } - if (scan_rc15 != SQLITE_DONE) { /* SCANCHK:15:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - out->visited = visited; - out->visited_count = n; - return CBM_STORE_ERR; - } - - sqlite3_finalize(stmt); - - out->visited = visited; - out->visited_count = n; - - /* Collect edges between visited nodes (including root) */ - if (n > 0) { - bfs_collect_edges(s, start_id, out->visited, n, types_clause, edge_types, edge_type_count, - &out->edges, &out->edge_count); - } else { - out->edges = NULL; - out->edge_count = 0; - } - - return CBM_STORE_OK; -} - -int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const char **edge_types, - int edge_type_count, int max_depth, int max_results, cbm_traverse_result_t *out) { - return store_bfs(s, start_id, direction, edge_types, edge_type_count, max_depth, max_results, - false, out); -} - -int cbm_store_bfs_with_edges(cbm_store_t *s, int64_t start_id, const char *direction, - const char **edge_types, int edge_type_count, int max_depth, - int max_results, cbm_traverse_result_t *out) { - return store_bfs(s, start_id, direction, edge_types, edge_type_count, max_depth, max_results, - true, out); -} - -/* ── Relationship-trail walk for Cypher variable-length paths ────── - * - * cbm_store_bfs reports each node once, at its shortest distance. A Cypher - * pattern such as [:CALLS*2..2] asks for paths of length 2, which may reach a - * node whose shortest distance is 1, and must not reuse a relationship (a - * self-loop cannot pad a path). So this walk reports every (node, hop) pair - * that some edge-distinct path reaches. - * - * Path enumeration is exponential, so the walk has a row budget: every node - * entered on a path counts one row, the start included. It goes deepest first - * and, among siblings, lower node id first, so a wide first hop cannot use up - * the budget before a deeper match is seen (upstream 0c5ffa50, 0d97729c, - * cd22487c). It reads edges through cbm_store_find_edges_by_{source,target}, - * so the SQLite, Zova workspace and query-backend stores share one semantics. */ -enum { ST_TRAIL_ROW_MULTIPLIER = 8, ST_TRAIL_MAX_ROWS = 4096 }; - -static int trail_row_limit(int max_results, int max_depth) { - long base = (long)(max_results > 0 ? max_results : ST_INIT_CAP_16) * ST_TRAIL_ROW_MULTIPLIER + - SKIP_ONE; - long rows = base * (max_depth > 0 ? max_depth : ST_INIT_CAP_16); - return rows > ST_TRAIL_MAX_ROWS ? ST_TRAIL_MAX_ROWS : (int)rows; -} - -typedef struct { - int64_t node; - int64_t *next; /* selected neighbours, lower id first */ - int count; -} trail_adj_t; - -typedef struct { - int adj; /* index into the adjacency cache */ - int cursor; /* next neighbour to try; the edge taken to reach the child is cursor - 1 */ -} trail_frame_t; - -static int trail_next_order(const void *a, const void *b) { - int64_t x = *(const int64_t *)a; - int64_t y = *(const int64_t *)b; - return x < y ? -1 : (x > y); -} - -/* Index of node's adjacency in the cache, loading it on first use. */ -static int trail_adjacency(cbm_store_t *s, trail_adj_t **cache, int *count, int *cap, - int64_t node, bool inbound, const char **edge_types, - int edge_type_count, int *rc) { - for (int i = 0; i < *count; i++) - if ((*cache)[i].node == node) return i; - cbm_edge_t *edges = NULL; - int edge_count = 0; - *rc = inbound ? cbm_store_find_edges_by_target(s, node, &edges, &edge_count) - : cbm_store_find_edges_by_source(s, node, &edges, &edge_count); - if (*rc == CBM_STORE_NOT_FOUND) *rc = CBM_STORE_OK; - if (*rc != CBM_STORE_OK) return CBM_NOT_FOUND; - trail_adj_t adj = {.node = node}; - adj.next = edge_count ? malloc((size_t)edge_count * sizeof(*adj.next)) : NULL; - for (int e = 0; e < edge_count && adj.next; e++) - if (bfs_edge_type_selected(edges[e].type, edge_types, edge_type_count)) - adj.next[adj.count++] = inbound ? edges[e].source_id : edges[e].target_id; - cbm_store_free_edges(edges, edge_count); - /* glibc declares qsort's base nonnull: an empty adjacency has next == NULL. */ - if (adj.count > 1) - qsort(adj.next, (size_t)adj.count, sizeof(*adj.next), trail_next_order); - if (*count == *cap) { - *cap = *cap ? *cap * ST_GROWTH : ST_INIT_CAP_16; - *cache = safe_realloc(*cache, (size_t)*cap * sizeof(**cache)); - } - (*cache)[*count] = adj; - return (*count)++; -} - -/* True when the edge (adjacency a, neighbour slot k) is already on the path. */ -static bool trail_uses_edge(const trail_frame_t *stack, int depth, int a, int k) { - for (int i = 0; i < depth; i++) - if (stack[i].adj == a && stack[i].cursor - SKIP_ONE == k) return true; - return false; -} - -static int trail_hop_order(const void *a, const void *b) { - const cbm_node_hop_t *x = a; - const cbm_node_hop_t *y = b; - if (x->hop != y->hop) return x->hop < y->hop ? -1 : 1; - return bfs_hop_order(a, b); -} - -int cbm_store_bfs_trail(cbm_store_t *s, int64_t start_id, const char *direction, - const char **edge_types, int edge_type_count, int max_depth, - int max_results, cbm_traverse_result_t *out) { - memset(out, 0, sizeof(*out)); - int rc = cbm_store_find_node_by_id(s, start_id, &out->root); - if (rc != CBM_STORE_OK) return rc; - if (max_results < 0) max_results = 0; - bool inbound = direction && strcmp(direction, "inbound") == 0; - int row_limit = trail_row_limit(max_results, max_depth); - - trail_adj_t *cache = NULL; - int cache_count = 0, cache_cap = 0; - trail_frame_t *stack = malloc(((size_t)(max_depth > 0 ? max_depth : 0) + SKIP_ONE) * - sizeof(*stack)); - cbm_node_hop_t *hops = NULL; /* distinct (node, hop) pairs; node holds only the id here */ - int hop_count = 0, hop_cap = 0; - int rows = SKIP_ONE; /* the start node */ - int depth = 0; - stack[0].adj = trail_adjacency(s, &cache, &cache_count, &cache_cap, start_id, inbound, - edge_types, edge_type_count, &rc); - stack[0].cursor = 0; - while (rc == CBM_STORE_OK && depth >= 0 && stack[0].adj >= 0) { - trail_frame_t *f = &stack[depth]; - const trail_adj_t *adj = &cache[f->adj]; - if (depth >= max_depth || f->cursor >= adj->count) { - depth--; - continue; - } - int k = f->cursor++; - if (trail_uses_edge(stack, depth, f->adj, k)) continue; - if (rows >= row_limit) { - out->truncated = true; - break; - } - rows++; - int64_t next = adj->next[k]; - bool seen = false; - for (int i = 0; i < hop_count && !seen; i++) - seen = hops[i].node.id == next && hops[i].hop == depth + SKIP_ONE; - if (!seen) { - if (hop_count == hop_cap) { - hop_cap = hop_cap ? hop_cap * ST_GROWTH : ST_INIT_CAP_16; - hops = safe_realloc(hops, (size_t)hop_cap * sizeof(*hops)); - } - memset(&hops[hop_count], 0, sizeof(hops[hop_count])); - hops[hop_count].node.id = next; - hops[hop_count++].hop = depth + SKIP_ONE; - } - if (depth + SKIP_ONE < max_depth) { - int child = trail_adjacency(s, &cache, &cache_count, &cache_cap, next, inbound, - edge_types, edge_type_count, &rc); - if (child >= 0) { - depth++; - stack[depth].adj = child; - stack[depth].cursor = 0; - } - } - } - for (int i = 0; i < cache_count; i++) free(cache[i].next); - free(cache); - free(stack); - if (out->truncated) { - char limit_buf[ST_BUF_16]; - snprintf(limit_buf, sizeof(limit_buf), "%d", row_limit); - cbm_log_warn("cypher.trail_truncated", "rows", limit_buf, "result", "partial"); - } - - /* Materialize each pair; a node reached at several hops is read once. */ - int kept = 0; - for (int i = 0; i < hop_count && rc == CBM_STORE_OK; i++) { - int64_t id = hops[i].node.id; - int prior = CBM_NOT_FOUND; - for (int j = 0; j < kept && prior < 0; j++) - if (hops[j].node.id == id) prior = j; - cbm_node_t node = {0}; - int nrc = prior >= 0 ? CBM_STORE_OK : cbm_store_find_node_by_id(s, id, &node); - if (nrc == CBM_STORE_NOT_FOUND) continue; - if (nrc != CBM_STORE_OK) { - rc = nrc; - break; - } - if (prior >= 0) { - const cbm_node_t *p = &hops[prior].node; - node = *p; - node.project = cbm_heap_strdup(p->project); - node.label = cbm_heap_strdup(p->label); - node.name = cbm_heap_strdup(p->name); - node.qualified_name = cbm_heap_strdup(p->qualified_name); - node.file_path = cbm_heap_strdup(p->file_path); - node.properties_json = cbm_heap_strdup(p->properties_json); - } - int hop = hops[i].hop; - hops[kept].node = node; - hops[kept++].hop = hop; - } - hop_count = kept; - if (hop_count > 0) - qsort(hops, (size_t)hop_count, sizeof(*hops), trail_hop_order); - if (hop_count > max_results) { - while (hop_count > max_results) cbm_node_free_fields(&hops[--hop_count].node); - out->truncated = true; - } - out->visited = hops; - out->visited_count = hop_count; - return rc; -} - -void cbm_store_traverse_free(cbm_traverse_result_t *out) { - if (!out) { - return; - } - /* Free root */ - safe_str_free(&out->root.project); - safe_str_free(&out->root.label); - safe_str_free(&out->root.name); - safe_str_free(&out->root.qualified_name); - safe_str_free(&out->root.file_path); - safe_str_free(&out->root.properties_json); - - /* Free visited */ - for (int i = 0; i < out->visited_count; i++) { - cbm_node_hop_t *h = &out->visited[i]; - safe_str_free(&h->node.project); - safe_str_free(&h->node.label); - safe_str_free(&h->node.name); - safe_str_free(&h->node.qualified_name); - safe_str_free(&h->node.file_path); - safe_str_free(&h->node.properties_json); - } - free(out->visited); - - /* Free edges */ - for (int i = 0; i < out->edge_count; i++) { - safe_str_free(&out->edges[i].from_name); - safe_str_free(&out->edges[i].to_name); - safe_str_free(&out->edges[i].type); - safe_str_free(&out->edges[i].properties_json); - } - free(out->edges); - - memset(out, 0, sizeof(*out)); -} - -/* ── Impact analysis ────────────────────────────────────────────── */ - -cbm_risk_level_t cbm_hop_to_risk(int hop) { - switch (hop) { - case SKIP_ONE: - return CBM_RISK_CRITICAL; - case ST_COL_2: - return CBM_RISK_HIGH; - case ST_COL_3: - return CBM_RISK_MEDIUM; - default: - return CBM_RISK_LOW; - } -} - -const char *cbm_risk_label(cbm_risk_level_t level) { - switch (level) { - case CBM_RISK_CRITICAL: - return "CRITICAL"; - case CBM_RISK_HIGH: - return "HIGH"; - case CBM_RISK_MEDIUM: - return "MEDIUM"; - case CBM_RISK_LOW: - default: - return "LOW"; - } -} - -cbm_impact_summary_t cbm_build_impact_summary(const cbm_node_hop_t *hops, int hop_count, - const cbm_edge_info_t *edges, int edge_count) { - cbm_impact_summary_t s = {0}; - for (int i = 0; i < hop_count; i++) { - switch (cbm_hop_to_risk(hops[i].hop)) { - case CBM_RISK_CRITICAL: - s.critical++; - break; - case CBM_RISK_HIGH: - s.high++; - break; - case CBM_RISK_MEDIUM: - s.medium++; - break; - case CBM_RISK_LOW: - s.low++; - break; - } - s.total++; - } - for (int i = 0; i < edge_count; i++) { - if (edges[i].type && (strcmp(edges[i].type, "HTTP_CALLS") == 0 || - strcmp(edges[i].type, "ASYNC_CALLS") == 0)) { - s.has_cross_service = true; - break; - } - } - return s; -} - -int cbm_deduplicate_hops(const cbm_node_hop_t *hops, int hop_count, cbm_node_hop_t **out, - int *out_count) { - *out = NULL; - *out_count = 0; - if (hop_count == 0) { - return CBM_STORE_OK; - } - - /* Simple O(n²) dedup — keep minimum hop per node ID */ - cbm_node_hop_t *result = malloc(hop_count * sizeof(cbm_node_hop_t)); - int n = 0; - - for (int i = 0; i < hop_count; i++) { - int found = ST_FOUND; - for (int j = 0; j < n; j++) { - if (result[j].node.id == hops[i].node.id) { - found = j; - break; - } - } - if (found >= 0) { - if (hops[i].hop < result[found].hop) { - result[found].hop = hops[i].hop; - } - } else { - result[n] = hops[i]; - n++; - } - } - - *out = safe_realloc(result, n * sizeof(cbm_node_hop_t)); - *out_count = n; - return CBM_STORE_OK; -} - -/* ── Schema ─────────────────────────────────────────────────────── */ - -enum { SCHEMA_MAX_JSON_KEYS = 50 }; - -/* Discover distinct JSON property keys for a table/column via json_each(). - * Prepends base_cols, then appends up to SCHEMA_MAX_JSON_KEYS from the query. - * Caller must free the returned array and each string in it. */ -static void schema_discover_props(sqlite3 *db, const char *sql, const char *project, - const char *filter, const char **base_cols, int base_col_count, - char ***out_props, int *out_count) { - int pcap = base_col_count + SCHEMA_MAX_JSON_KEYS; - char **props = malloc(pcap * sizeof(char *)); - int pn = 0; - - for (int b = 0; b < base_col_count; b++) { - props[pn++] = cbm_heap_strdup(base_cols[b]); - } - - sqlite3_stmt *pstmt = NULL; - if (sqlite3_prepare_v2(db, sql, CBM_NOT_FOUND, &pstmt, NULL) == SQLITE_OK) { - bind_text(pstmt, SKIP_ONE, project); - bind_text(pstmt, PAIR_LEN, filter); - while (sqlite3_step(pstmt) == SQLITE_ROW && pn < pcap) { - props[pn++] = cbm_heap_strdup((const char *)sqlite3_column_text(pstmt, 0)); - } - sqlite3_finalize(pstmt); - } - - *out_props = props; - *out_count = pn; -} - -/* Path scoping for architecture / schema (shared). */ -static bool arch_path_is_set(const char *path) { - if (!path) { - return false; - } - while (*path == ' ' || *path == '\t' || *path == '\n' || *path == '\r') { - path++; - } - return path[0] != '\0'; -} - -static bool arch_path_prepare(const char *path, char *norm_out, size_t norm_sz, char *like_out, - size_t like_sz) { - if (!arch_path_is_set(path)) { - return false; - } - while (*path == ' ' || *path == '\t' || *path == '\n' || *path == '\r') { - path++; - } - if (path[0] == '\0') { - return false; - } - if (strncmp(path, "./", 2) == 0) { - path += 2; - } - while (*path == '/') { - path++; - } - if (path[0] == '\0') { - return false; - } - strncpy(norm_out, path, norm_sz - 1); - norm_out[norm_sz - 1] = '\0'; - size_t len = strlen(norm_out); - while (len > 0 && - (norm_out[len - 1] == ' ' || norm_out[len - 1] == '\t' || norm_out[len - 1] == '/')) { - norm_out[--len] = '\0'; - } - /* Collapse duplicate slashes */ - size_t w = 0; - for (size_t r = 0; norm_out[r] != '\0'; r++) { - if (norm_out[r] == '/' && w > 0 && norm_out[w - 1] == '/') { - continue; - } - norm_out[w++] = norm_out[r]; - } - norm_out[w] = '\0'; - if (norm_out[0] == '\0') { - return false; - } - snprintf(like_out, like_sz, "%s/%%", norm_out); - return true; -} - -static const char *arch_path_scope_sql(void) { - return " AND (file_path = ? OR file_path LIKE ?)"; -} - -static void arch_bind_path_scope(sqlite3_stmt *stmt, int exact_idx, int like_idx, const char *norm, - const char *like_pat) { - bind_text(stmt, exact_idx, norm); - bind_text(stmt, like_idx, like_pat); -} - -bool cbm_store_arch_path_scoped(const char *path) { - char norm[CBM_SZ_512]; - char like[CBM_SZ_512 + 4]; - return arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like)); -} - -bool cbm_store_normalize_arch_path(const char *path, char *norm_out, size_t norm_sz) { - char like[CBM_SZ_512 + 4]; - return arch_path_prepare(path, norm_out, norm_sz, like, sizeof(like)); -} - -/* Ids of the nodes view rows under a prepared path scope, sorted ascending. */ -static int store_scope_node_ids(cbm_store_t *s, const char *project, const char *norm, - const char *like, int64_t **out, int *out_count) { - *out = NULL; - *out_count = 0; - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, - "SELECT id FROM nodes WHERE project = ?1 " - "AND (file_path = ?2 OR file_path LIKE ?3) ORDER BY id;", - CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "scope_node_ids"); - return CBM_STORE_ERR; - } - bind_text(stmt, ST_COL_1, project); - arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like); - int cap = 0; - int n = 0; - int64_t *ids = NULL; - int rc; - while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n == cap) { - cap = cap ? cap * ST_GROWTH : CBM_SZ_256; - int64_t *grown = realloc(ids, (size_t)cap * sizeof(*ids)); - if (!grown) { - rc = SQLITE_NOMEM; - break; - } - ids = grown; - } - ids[n++] = sqlite3_column_int64(stmt, 0); - } - sqlite3_finalize(stmt); - if (rc != SQLITE_DONE) { - free(ids); - store_set_error(s, "scope_node_ids"); - return CBM_STORE_ERR; - } - *out = ids; - *out_count = n; - return CBM_STORE_OK; -} - -static int store_type_count_compare(const void *left_ptr, const void *right_ptr) { - const cbm_type_count_t *left = left_ptr; - const cbm_type_count_t *right = right_ptr; - if (left->count != right->count) { - return left->count > right->count ? CBM_NOT_FOUND : SKIP_ONE; - } - return strcmp(left->type, right->type); -} - -/* Edge type counts of a Zova workspace store, most frequent first. With - * scoped, only edges whose two ends are in the sorted scope ids count. */ -static int store_zova_edge_type_counts(cbm_store_t *s, bool scoped, const int64_t *scope, - int scope_count, cbm_type_count_t **out, - int *out_count) { - *out = NULL; - *out_count = 0; - if (store_zova_edges(s) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - int types = s->zova_edge_type_count; - int *counts = calloc((size_t)types + SKIP_ONE, sizeof(*counts)); - cbm_type_count_t *arr = calloc((size_t)types + SKIP_ONE, sizeof(*arr)); - if (!counts || !arr) { - free(counts); - free(arr); - return CBM_STORE_ERR; - } - for (int i = 0; i < s->zova_edge_count; i++) { - const store_zova_edge_t *e = &s->zova_edges[i]; - if (!scoped || (store_id_in(scope, scope_count, e->source_key) && - store_id_in(scope, scope_count, e->target_key))) { - counts[e->type]++; - } - } - int n = 0; - for (int t = 0; t < types; t++) { - if (counts[t] == 0) { - continue; - } - arr[n].type = cbm_heap_strdup(s->zova_edge_types[t]); - arr[n].count = counts[t]; - if (!arr[n].type) { - for (int i = 0; i < n; i++) { - safe_str_free(&arr[i].type); - } - free(counts); - free(arr); - return CBM_STORE_ERR; - } - n++; - } - free(counts); - qsort(arr, (size_t)n, sizeof(*arr), store_type_count_compare); - *out = arr; - *out_count = n; - return CBM_STORE_OK; -} - -typedef struct { - cbm_schema_info_t *schema; - char ***keys; - int *key_counts; - int *key_caps; - bool failed; -} store_edge_keys_t; - -static int store_edge_keys_visit(int64_t source_key, int64_t target_key, const char *type, - size_t type_len, const uint8_t *properties, - size_t properties_len, void *context_ptr) { - (void)source_key; - (void)target_key; - store_edge_keys_t *context = context_ptr; - if (properties_len == 0 || (properties_len == PAIR_LEN && memcmp(properties, "{}", 2) == 0)) { - return 0; - } - int t = CBM_NOT_FOUND; - for (int i = 0; i < context->schema->edge_type_count; i++) { - const char *name = context->schema->edge_types[i].type; - if (strlen(name) == type_len && memcmp(name, type, type_len) == 0) { - t = i; - break; - } - } - if (t < 0) { - return 0; - } - yyjson_doc *doc = yyjson_read((const char *)properties, properties_len, 0); - yyjson_val *root = yyjson_doc_get_root(doc); - yyjson_obj_iter iter; - yyjson_val *key; - if (yyjson_is_obj(root) && yyjson_obj_iter_init(root, &iter)) { - while ((key = yyjson_obj_iter_next(&iter)) != NULL) { - const char *k = yyjson_get_str(key); - bool seen = false; - for (int i = 0; k && !seen && i < context->key_counts[t]; i++) { - seen = strcmp(context->keys[t][i], k) == 0; - } - if (!k || seen) { - continue; - } - if (context->key_counts[t] == context->key_caps[t]) { - int cap = context->key_caps[t] ? context->key_caps[t] * ST_GROWTH : ST_INIT_CAP_8; - char **grown = realloc(context->keys[t], (size_t)cap * sizeof(*grown)); - if (!grown) { - context->failed = true; - break; - } - context->keys[t] = grown; - context->key_caps[t] = cap; - } - char *copy = cbm_heap_strdup(k); - if (!copy) { - context->failed = true; - break; - } - context->keys[t][context->key_counts[t]++] = copy; - } - } - yyjson_doc_free(doc); - return context->failed ? CBM_NOT_FOUND : 0; -} - -static int store_name_ptr_compare(const void *left_ptr, const void *right_ptr) { - return strcmp(*(char *const *)left_ptr, *(char *const *)right_ptr); -} - -/* Property keys of each edge type in schema, as get_schema_impl lists them: - * the base columns, then up to SCHEMA_MAX_JSON_KEYS distinct JSON keys in - * key order, read from the edge payloads in the Zova graph. */ -static int store_zova_edge_property_keys(cbm_store_t *s, cbm_schema_info_t *schema, - const char **base_cols, int base_col_count) { - int types = schema->edge_type_count; - store_edge_keys_t context = { - .schema = schema, - .keys = calloc((size_t)types + SKIP_ONE, sizeof(char **)), - .key_counts = calloc((size_t)types + SKIP_ONE, sizeof(int)), - .key_caps = calloc((size_t)types + SKIP_ONE, sizeof(int)), - }; - int rc = context.keys && context.key_counts && context.key_caps && - cbm_zova_repository_visit_edges(s->zova_repository, - s->zova_graph_workspace_id, NULL, - store_edge_keys_visit, - &context) == CBM_STORE_OK - ? CBM_STORE_OK - : CBM_STORE_ERR; - for (int t = 0; t < types; t++) { - int found = context.key_counts ? context.key_counts[t] : 0; - char **keys = context.keys ? context.keys[t] : NULL; - if (rc == CBM_STORE_OK) { - if (found > 1) { - qsort(keys, (size_t)found, sizeof(*keys), store_name_ptr_compare); - } - int take = found < SCHEMA_MAX_JSON_KEYS ? found : SCHEMA_MAX_JSON_KEYS; - char **props = malloc((size_t)(base_col_count + take + SKIP_ONE) * sizeof(*props)); - if (props) { - int pn = 0; - for (int b = 0; b < base_col_count; b++) { - props[pn++] = cbm_heap_strdup(base_cols[b]); - } - for (int k = 0; k < take; k++) { - props[pn++] = keys[k]; - keys[k] = NULL; - } - schema->edge_types[t].properties = props; - schema->edge_types[t].property_count = pn; - } else { - rc = CBM_STORE_ERR; - } - } - for (int k = 0; k < found; k++) { - free(keys[k]); - } - free(keys); - } - free(context.keys); - free(context.key_counts); - free(context.key_caps); - if (rc != CBM_STORE_OK) { - store_set_error(s, "zova edge property keys"); - } - return rc; -} - -int cbm_store_count_nodes_scoped(cbm_store_t *s, const char *project, const char *path) { - if (!s || !s->db || !project) { - return 0; - } - if (store_legacy_scope(s, &project) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - char norm[CBM_SZ_512]; - char like[CBM_SZ_512]; - if (!arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like))) { - return cbm_store_count_nodes(s, project); - } - const char *sql = "SELECT COUNT(*) FROM nodes WHERE project = ?1 " - "AND (file_path = ?2 OR file_path LIKE ?3);"; - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) { - if (stmt) { - sqlite3_finalize(stmt); - } - return CBM_STORE_ERR; - } - bind_text(stmt, ST_COL_1, project); - arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like); - int n = 0; - if (sqlite3_step(stmt) == SQLITE_ROW) { - n = sqlite3_column_int(stmt, 0); - } - sqlite3_finalize(stmt); - return n; -} - -int cbm_store_count_edges_scoped(cbm_store_t *s, const char *project, const char *path) { - if (!s || !s->db || !project) { - return 0; - } - if (store_legacy_scope(s, &project) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - char norm[CBM_SZ_512]; - char like[CBM_SZ_512]; - if (!arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like))) { - return cbm_store_count_edges(s, project); - } - if (store_is_zova_workspace(s)) { - int64_t *scope = NULL; - int scope_count = 0; - if (store_scope_node_ids(s, project, norm, like, &scope, &scope_count) != CBM_STORE_OK || - store_zova_edges(s) != CBM_STORE_OK) { - free(scope); - return CBM_STORE_ERR; - } - int n = 0; - for (int i = 0; i < s->zova_edge_count; i++) { - n += store_id_in(scope, scope_count, s->zova_edges[i].source_key) && - store_id_in(scope, scope_count, s->zova_edges[i].target_key); - } - free(scope); - return n; - } - const char *sql = - "SELECT COUNT(*) FROM edges e WHERE e.project = ?1 " - "AND EXISTS (SELECT 1 FROM nodes ns WHERE ns.id = e.source_id AND ns.project = ?1 " - "AND (ns.file_path = ?2 OR ns.file_path LIKE ?3)) " - "AND EXISTS (SELECT 1 FROM nodes nt WHERE nt.id = e.target_id AND nt.project = ?1 " - "AND (nt.file_path = ?2 OR nt.file_path LIKE ?3));"; - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) { - if (stmt) { - sqlite3_finalize(stmt); - } - return CBM_STORE_ERR; - } - bind_text(stmt, ST_COL_1, project); - arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like); - int n = 0; - if (sqlite3_step(stmt) == SQLITE_ROW) { - n = sqlite3_column_int(stmt, 0); - } - sqlite3_finalize(stmt); - return n; -} - -/* with_props=false skips the per-label/per-type JSON property-key discovery: - * those json_each() scans walk EVERY row of each label/type (minutes-scale on - * multi-million-node graphs) and get_architecture only needs the counts. */ -static int get_schema_impl(cbm_store_t *s, const char *project, cbm_schema_info_t *out, - bool with_props) { - memset(out, 0, sizeof(*out)); - if (!s || !s->db) { - return CBM_NOT_FOUND; - } - if (store_legacy_scope(s, &project) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - - /* Node labels */ - { - const char *sql = "SELECT label, COUNT(*) FROM nodes WHERE project = ?1 GROUP BY label " - "ORDER BY COUNT(*) DESC;"; - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) { - if (stmt) { - sqlite3_finalize(stmt); - } - return CBM_NOT_FOUND; - } - bind_text(stmt, SKIP_ONE, project); - - int cap = ST_INIT_CAP_8; - int n = 0; - cbm_label_count_t *arr = malloc(cap * sizeof(cbm_label_count_t)); - if (!arr) { - sqlite3_finalize(stmt); - return CBM_NOT_FOUND; - } - int scan_rc16; - while ((scan_rc16 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - int new_cap = cap * ST_GROWTH; - void *tmp = realloc(arr, new_cap * sizeof(cbm_label_count_t)); - if (!tmp) { - for (int i = 0; i < n; i++) { - safe_str_free(&arr[i].label); - } - free(arr); - sqlite3_finalize(stmt); - return CBM_NOT_FOUND; - } - arr = tmp; - cap = new_cap; - } - arr[n].label = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, 0)); - arr[n].count = sqlite3_column_int(stmt, SKIP_ONE); - arr[n].properties = NULL; - arr[n].property_count = 0; - n++; - } - if (scan_rc16 != SQLITE_DONE) { /* SCANCHK:16:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - out->node_labels = arr; - out->node_label_count = n; - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - out->node_labels = arr; - out->node_label_count = n; - } - - /* Node label property keys: base columns + distinct JSON property keys per label */ - if (with_props) { - static const char *node_base_cols[] = {"name", "qualified_name", "file_path", "start_line", - "end_line"}; - const char *prop_sql = "SELECT DISTINCT je.key " - "FROM nodes, json_each(nodes.properties) AS je " - "WHERE nodes.project = ?1 AND nodes.label = ?2 " - " AND nodes.properties != '{}' " - "ORDER BY je.key " - "LIMIT 50;"; - - for (int i = 0; i < out->node_label_count; i++) { - schema_discover_props( - s->db, prop_sql, project, out->node_labels[i].label, node_base_cols, - (int)(sizeof(node_base_cols) / sizeof(node_base_cols[0])), - &out->node_labels[i].properties, &out->node_labels[i].property_count); - } - } - - /* Edge types */ - if (store_is_zova_workspace(s)) { - if (store_zova_edge_type_counts(s, false, NULL, 0, &out->edge_types, - &out->edge_type_count) != CBM_STORE_OK) { - cbm_store_schema_free(out); - return CBM_STORE_ERR; - } - } else { - const char *sql = "SELECT type, COUNT(*) FROM edges WHERE project = ?1 GROUP BY type ORDER " - "BY COUNT(*) DESC;"; - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) { - if (stmt) { - sqlite3_finalize(stmt); - } - cbm_store_schema_free(out); - return CBM_NOT_FOUND; - } - bind_text(stmt, SKIP_ONE, project); - - int cap = ST_INIT_CAP_8; - int n = 0; - cbm_type_count_t *arr = malloc(cap * sizeof(cbm_type_count_t)); - if (!arr) { - sqlite3_finalize(stmt); - cbm_store_schema_free(out); - return CBM_NOT_FOUND; - } - int scan_rc17; - while ((scan_rc17 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - int new_cap = cap * ST_GROWTH; - void *tmp = realloc(arr, new_cap * sizeof(cbm_type_count_t)); - if (!tmp) { - for (int i = 0; i < n; i++) { - safe_str_free(&arr[i].type); - } - free(arr); - sqlite3_finalize(stmt); - cbm_store_schema_free(out); - return CBM_NOT_FOUND; - } - arr = tmp; - cap = new_cap; - } - arr[n].type = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, 0)); - arr[n].count = sqlite3_column_int(stmt, SKIP_ONE); - arr[n].properties = NULL; - arr[n].property_count = 0; - n++; - } - if (scan_rc17 != SQLITE_DONE) { /* SCANCHK:17:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - out->edge_types = arr; - out->edge_type_count = n; - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - out->edge_types = arr; - out->edge_type_count = n; - } - - /* Edge type property keys: base columns + distinct JSON property keys per type */ - if (with_props && store_is_zova_workspace(s)) { - static const char *edge_base_cols[] = {"source_id", "target_id"}; - if (store_zova_edge_property_keys(s, out, edge_base_cols, - (int)(sizeof(edge_base_cols) / - sizeof(edge_base_cols[0]))) != CBM_STORE_OK) { - cbm_store_schema_free(out); - return CBM_STORE_ERR; - } - } else if (with_props) { - static const char *edge_base_cols[] = {"source_id", "target_id"}; - const char *prop_sql = "SELECT DISTINCT je.key " - "FROM edges, json_each(edges.properties) AS je " - "WHERE edges.project = ?1 AND edges.type = ?2 " - " AND edges.properties != '{}' " - "ORDER BY je.key " - "LIMIT 50;"; - - for (int i = 0; i < out->edge_type_count; i++) { - schema_discover_props(s->db, prop_sql, project, out->edge_types[i].type, edge_base_cols, - (int)(sizeof(edge_base_cols) / sizeof(edge_base_cols[0])), - &out->edge_types[i].properties, - &out->edge_types[i].property_count); - } - } - - return CBM_STORE_OK; -} - -int cbm_store_get_schema(cbm_store_t *s, const char *project, cbm_schema_info_t *out) { - return get_schema_impl(s, project, out, true); -} - -int cbm_store_get_schema_counts(cbm_store_t *s, const char *project, cbm_schema_info_t *out) { - return get_schema_impl(s, project, out, false); -} - -int cbm_store_get_schema_counts_scoped(cbm_store_t *s, const char *project, const char *path, - cbm_schema_info_t *out) { - memset(out, 0, sizeof(*out)); - if (!s || !s->db) { - return CBM_NOT_FOUND; - } - if (store_legacy_scope(s, &project) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - char norm[CBM_SZ_512]; - char like[CBM_SZ_512]; - bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like)); - if (!scoped) { - return get_schema_impl(s, project, out, false); - } - - char sqlbuf[ST_SQL_BUF]; - { - const char *base = "SELECT label, COUNT(*) FROM nodes WHERE project = ?1"; - snprintf(sqlbuf, sizeof(sqlbuf), "%s%s GROUP BY label ORDER BY COUNT(*) DESC;", base, - arch_path_scope_sql()); - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) { - if (stmt) { - sqlite3_finalize(stmt); - } - return CBM_NOT_FOUND; - } - bind_text(stmt, SKIP_ONE, project); - arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like); - - int cap = ST_INIT_CAP_8; - int n = 0; - cbm_label_count_t *arr = malloc(cap * sizeof(cbm_label_count_t)); - if (!arr) { - sqlite3_finalize(stmt); - return CBM_NOT_FOUND; - } - int scan_rc18; - while ((scan_rc18 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - int new_cap = cap * ST_GROWTH; - void *tmp = realloc(arr, new_cap * sizeof(cbm_label_count_t)); - if (!tmp) { - for (int i = 0; i < n; i++) { - safe_str_free(&arr[i].label); - } - free(arr); - sqlite3_finalize(stmt); - return CBM_NOT_FOUND; - } - arr = tmp; - cap = new_cap; - } - arr[n].label = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, 0)); - arr[n].count = sqlite3_column_int(stmt, SKIP_ONE); - arr[n].properties = NULL; - arr[n].property_count = 0; - n++; - } - if (scan_rc18 != SQLITE_DONE) { /* SCANCHK:18:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - out->node_labels = arr; - out->node_label_count = n; - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - out->node_labels = arr; - out->node_label_count = n; - } - - if (store_is_zova_workspace(s)) { - int64_t *scope = NULL; - int scope_count = 0; - int rc = store_scope_node_ids(s, project, norm, like, &scope, &scope_count); - if (rc == CBM_STORE_OK) { - rc = store_zova_edge_type_counts(s, true, scope, scope_count, &out->edge_types, - &out->edge_type_count); - } - free(scope); - if (rc != CBM_STORE_OK) { - cbm_store_schema_free(out); - return CBM_STORE_ERR; - } - } else { - const char *esql = - "SELECT e.type, COUNT(*) FROM edges e WHERE e.project = ?1 " - "AND EXISTS (SELECT 1 FROM nodes ns WHERE ns.id = e.source_id AND ns.project = ?1 " - "AND (ns.file_path = ?2 OR ns.file_path LIKE ?3)) " - "AND EXISTS (SELECT 1 FROM nodes nt WHERE nt.id = e.target_id AND nt.project = ?1 " - "AND (nt.file_path = ?2 OR nt.file_path LIKE ?3)) " - "GROUP BY e.type ORDER BY COUNT(*) DESC;"; - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, esql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) { - if (stmt) { - sqlite3_finalize(stmt); - } - cbm_store_schema_free(out); - return CBM_NOT_FOUND; - } - bind_text(stmt, SKIP_ONE, project); - arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like); - - int cap = ST_INIT_CAP_8; - int n = 0; - cbm_type_count_t *arr = malloc(cap * sizeof(cbm_type_count_t)); - if (!arr) { - sqlite3_finalize(stmt); - cbm_store_schema_free(out); - return CBM_NOT_FOUND; - } - int scan_rc19; - while ((scan_rc19 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - int new_cap = cap * ST_GROWTH; - void *tmp = realloc(arr, new_cap * sizeof(cbm_type_count_t)); - if (!tmp) { - for (int i = 0; i < n; i++) { - safe_str_free(&arr[i].type); - } - free(arr); - sqlite3_finalize(stmt); - cbm_store_schema_free(out); - return CBM_NOT_FOUND; - } - arr = tmp; - cap = new_cap; - } - arr[n].type = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, 0)); - arr[n].count = sqlite3_column_int(stmt, SKIP_ONE); - arr[n].properties = NULL; - arr[n].property_count = 0; - n++; - } - if (scan_rc19 != SQLITE_DONE) { /* SCANCHK:19:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - out->edge_types = arr; - out->edge_type_count = n; - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - out->edge_types = arr; - out->edge_type_count = n; - } - - return CBM_STORE_OK; -} - -void cbm_store_schema_free(cbm_schema_info_t *out) { - if (!out) { - return; - } - for (int i = 0; i < out->node_label_count; i++) { - safe_str_free(&out->node_labels[i].label); - for (int j = 0; j < out->node_labels[i].property_count; j++) { - free(out->node_labels[i].properties[j]); - } - free(out->node_labels[i].properties); - } - free(out->node_labels); - - for (int i = 0; i < out->edge_type_count; i++) { - safe_str_free(&out->edge_types[i].type); - for (int j = 0; j < out->edge_types[i].property_count; j++) { - free(out->edge_types[i].properties[j]); - } - free(out->edge_types[i].properties); - } - free(out->edge_types); - - for (int i = 0; i < out->rel_pattern_count; i++) { - safe_str_free(&out->rel_patterns[i]); - } - free(out->rel_patterns); - - for (int i = 0; i < out->sample_func_count; i++) { - safe_str_free(&out->sample_func_names[i]); - } - free(out->sample_func_names); - - for (int i = 0; i < out->sample_class_count; i++) { - safe_str_free(&out->sample_class_names[i]); - } - free(out->sample_class_names); - - for (int i = 0; i < out->sample_qn_count; i++) { - safe_str_free(&out->sample_qns[i]); - } - free(out->sample_qns); - - memset(out, 0, sizeof(*out)); -} - -/* ── Architecture helpers ───────────────────────────────────────── */ - -/* Extract sub-package from QN: project.dir1.dir2.sym → dir1 (4+ parts → [2], else [1]) */ -const char *cbm_qn_to_package(const char *qn) { - if (!qn || !qn[0]) { - return ""; - } - static CBM_TLS char buf[CBM_SZ_256]; - /* Find dots and extract segment */ - const char *dots[ST_QN_MAX_DOTS] = {NULL}; - int ndots = 0; - for (const char *p = qn; *p && ndots < ST_QN_MAX_DOTS; p++) { - if (*p == '.') { - dots[ndots++] = p; - } - } - /* 4+ segments: return segment[2] */ - if (ndots >= ST_QN_MIN_DOTS) { - const char *start = dots[SKIP_ONE] + SKIP_ONE; - int len = (int)(dots[ST_COL_2] - start); - if (len > 0 && len < (int)sizeof(buf)) { - memcpy(buf, start, len); - buf[len] = '\0'; - return buf; - } - } - /* 2+ segments: return segment[1] */ - if (ndots >= SKIP_ONE) { - const char *start = dots[0] + SKIP_ONE; - const char *end = (ndots >= ST_COL_2) ? dots[SKIP_ONE] : qn + strlen(qn); - int len = (int)(end - start); - if (len > 0 && len < (int)sizeof(buf)) { - memcpy(buf, start, len); - buf[len] = '\0'; - return buf; - } - } - return ""; -} - -/* Extract top-level package from QN: project.dir1.rest → dir1 (segment[1]) */ -const char *cbm_qn_to_top_package(const char *qn) { - if (!qn || !qn[0]) { - return ""; - } - static CBM_TLS char buf[CBM_SZ_256]; - const char *first_dot = strchr(qn, '.'); - if (!first_dot) { - return ""; - } - const char *start = first_dot + SKIP_ONE; - const char *second_dot = strchr(start, '.'); - const char *end = second_dot ? second_dot : qn + strlen(qn); - int len = (int)(end - start); - if (len > 0 && len < (int)sizeof(buf)) { - memcpy(buf, start, len); - buf[len] = '\0'; - return buf; - } - return ""; -} - -bool cbm_is_test_file_path(const char *fp) { - if (!fp || fp[0] == '\0') { - return false; - } - return strstr(fp, "test") != NULL; -} - -/* File extension → language name mapping (table-driven) */ -typedef struct { - const char *ext; - const char *lang; -} ext_lang_entry_t; - -static const ext_lang_entry_t ext_lang_table[] = { - {".py", "Python"}, {".go", "Go"}, {".js", "JavaScript"}, {".jsx", "JavaScript"}, - {".ts", "TypeScript"}, {".tsx", "TypeScript"}, {".rs", "Rust"}, {".java", "Java"}, - {".cpp", "C++"}, {".cc", "C++"}, {".cxx", "C++"}, {".c", "C"}, - {".h", "C"}, {".cs", "C#"}, {".php", "PHP"}, {".lua", "Lua"}, - {".scala", "Scala"}, {".kt", "Kotlin"}, {".rb", "Ruby"}, {".sh", "Bash"}, - {".bash", "Bash"}, {".zig", "Zig"}, {".ex", "Elixir"}, {".exs", "Elixir"}, - {".hs", "Haskell"}, {".ml", "OCaml"}, {".mli", "OCaml"}, {".html", "HTML"}, - {".css", "CSS"}, {".yaml", "YAML"}, {".yml", "YAML"}, {".toml", "TOML"}, - {".hcl", "HCL"}, {".tf", "HCL"}, {".sql", "SQL"}, {".erl", "Erlang"}, - {".swift", "Swift"}, {".dart", "Dart"}, {".groovy", "Groovy"}, {".pl", "Perl"}, - {".r", "R"}, {".scss", "SCSS"}, {".vue", "Vue"}, {".svelte", "Svelte"}, - {NULL, NULL}, -}; - -static const char *ext_to_lang(const char *ext) { - if (!ext) { - return NULL; - } - for (const ext_lang_entry_t *e = ext_lang_table; e->ext; e++) { - if (strcmp(ext, e->ext) == 0) { - return e->lang; - } - } - return NULL; -} - -/* Get lowercase file extension from path, dot included. NULL if none or too long. */ -static const char *file_ext_lower(const char *path) { - const char *dot = cbm_path_last_dot(path); - if (!dot[0]) { - return NULL; - } - static CBM_TLS char buf[CBM_SZ_16]; - int len = (int)strlen(dot); - if (len >= (int)sizeof(buf)) { - return NULL; - } - for (int i = 0; i < len; i++) { - buf[i] = (char)((dot[i] >= 'A' && dot[i] <= 'Z') ? dot[i] + CBM_SZ_32 : dot[i]); - } - buf[len] = '\0'; - return buf; -} - -/* ── Architecture aspect implementations ───────────────────────── */ - -static int arch_languages(cbm_store_t *s, const char *project, const char *path, - cbm_architecture_info_t *out) { - char norm[CBM_SZ_512]; - char like[CBM_SZ_512]; - bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like)); - char sqlbuf[ST_SQL_BUF]; - const char *base = "SELECT file_path FROM nodes WHERE project=?1 AND label='File'"; - if (scoped) { - snprintf(sqlbuf, sizeof(sqlbuf), "%s%s", base, arch_path_scope_sql()); - } else { - snprintf(sqlbuf, sizeof(sqlbuf), "%s", base); - } - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "arch_languages"); - return CBM_STORE_ERR; - } - bind_text(stmt, SKIP_ONE, project); - if (scoped) { - arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like); - } - - /* Count per language using a simple parallel array */ - const char *lang_names[CBM_SZ_64]; - int lang_counts[CBM_SZ_64]; - int nlang = 0; - - int scan_rc20; - while ((scan_rc20 = sqlite3_step(stmt)) == SQLITE_ROW) { - const char *fp = (const char *)sqlite3_column_text(stmt, 0); - const char *ext = file_ext_lower(fp); - const char *lang = ext_to_lang(ext); - if (!lang) { - continue; - } - int found = ST_FOUND; - for (int i = 0; i < nlang; i++) { - if (strcmp(lang_names[i], lang) == 0) { - found = i; - break; - } - } - if (found >= 0) { - lang_counts[found]++; - } else if (nlang < CBM_SZ_64) { - lang_names[nlang] = lang; - lang_counts[nlang] = SKIP_ONE; - nlang++; - } - } - if (scan_rc20 != SQLITE_DONE) { /* SCANCHK:20:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - - /* Sort by count descending (simple insertion sort) */ - for (int i = SKIP_ONE; i < nlang; i++) { - int j = i; - while (j > 0 && lang_counts[j] > lang_counts[j - SKIP_ONE]) { - int tc = lang_counts[j]; - lang_counts[j] = lang_counts[j - SKIP_ONE]; - lang_counts[j - SKIP_ONE] = tc; - const char *tn = lang_names[j]; - lang_names[j] = lang_names[j - SKIP_ONE]; - lang_names[j - SKIP_ONE] = tn; - j--; - } - } - if (nlang > CBM_DECIMAL_BASE) { - nlang = ST_MAX_LANG; - } - - out->languages = (nlang > 0) ? calloc(nlang, sizeof(cbm_language_count_t)) : NULL; - out->language_count = nlang; - for (int i = 0; i < nlang; i++) { - out->languages[i].language = cbm_heap_strdup(lang_names[i]); - out->languages[i].file_count = lang_counts[i]; - } - return CBM_STORE_OK; -} - -static int arch_entry_points(cbm_store_t *s, const char *project, const char *path, - cbm_architecture_info_t *out) { - char norm[CBM_SZ_512]; - char like[CBM_SZ_512]; - bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like)); - char sqlbuf[ST_SQL_BUF]; - const char *base = "SELECT name, qualified_name, file_path FROM nodes " - "WHERE project=?1 AND json_extract(properties, '$.is_entry_point') = 1 " - "AND (json_extract(properties, '$.is_test') IS NULL OR " - "json_extract(properties, '$.is_test') != 1) " - "AND file_path NOT LIKE '%test%'"; - if (scoped) { - snprintf(sqlbuf, sizeof(sqlbuf), "%s%s LIMIT 20", base, arch_path_scope_sql()); - } else { - snprintf(sqlbuf, sizeof(sqlbuf), "%s LIMIT 20", base); - } - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "arch_entry_points"); - return CBM_STORE_ERR; - } - bind_text(stmt, SKIP_ONE, project); - if (scoped) { - arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like); - } - - int cap = ST_INIT_CAP_8; - int n = 0; - cbm_entry_point_t *arr = calloc(cap, sizeof(cbm_entry_point_t)); - int scan_rc21; - while ((scan_rc21 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - arr = safe_realloc(arr, cap * sizeof(cbm_entry_point_t)); - } - arr[n].name = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, 0)); - arr[n].qualified_name = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE)); - arr[n].file = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_2)); - n++; - } - if (scan_rc21 != SQLITE_DONE) { /* SCANCHK:21:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - out->entry_points = arr; - out->entry_point_count = n; - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - out->entry_points = arr; - out->entry_point_count = n; - return CBM_STORE_OK; -} - -/* Extract a JSON string value from a simple JSON object by key name. */ -static char *extract_json_string_prop(const char *json, const char *key, int key_len) { - if (!json) { - return NULL; - } - const char *m = strstr(json, key); - if (!m) { - return NULL; - } - m = strchr(m + key_len, '"'); - if (!m) { - return NULL; - } - m++; - const char *end = strchr(m, '"'); - if (!end || end - m >= CBM_SZ_256) { - return NULL; - } - char vbuf[CBM_SZ_256]; - memcpy(vbuf, m, end - m); - vbuf[end - m] = '\0'; - return cbm_heap_strdup(vbuf); -} - -static int arch_routes(cbm_store_t *s, const char *project, const char *path, - cbm_architecture_info_t *out) { - char norm[CBM_SZ_512]; - char like[CBM_SZ_512]; - bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like)); - char sqlbuf[ST_SQL_BUF]; - const char *base = "SELECT name, properties, COALESCE(file_path, '') FROM nodes " - "WHERE project=?1 AND label='Route' " - "AND (json_extract(properties, '$.is_test') IS NULL OR " - "json_extract(properties, '$.is_test') != 1)"; - if (scoped) { - snprintf(sqlbuf, sizeof(sqlbuf), "%s%s LIMIT 20", base, arch_path_scope_sql()); - } else { - snprintf(sqlbuf, sizeof(sqlbuf), "%s LIMIT 20", base); - } - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "arch_routes"); - return CBM_STORE_ERR; - } - bind_text(stmt, SKIP_ONE, project); - if (scoped) { - arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like); - } - - int cap = ST_INIT_CAP_8; - int n = 0; - cbm_route_info_t *arr = calloc(cap, sizeof(cbm_route_info_t)); - int scan_rc22; - while ((scan_rc22 = sqlite3_step(stmt)) == SQLITE_ROW) { - const char *name = (const char *)sqlite3_column_text(stmt, 0); - const char *props = (const char *)sqlite3_column_text(stmt, SKIP_ONE); - const char *fp = (const char *)sqlite3_column_text(stmt, CBM_SZ_2); - if (cbm_is_test_file_path(fp)) { - continue; - } - if (n >= cap) { - cap *= ST_GROWTH; - arr = safe_realloc(arr, cap * sizeof(cbm_route_info_t)); - } - - arr[n].method = cbm_heap_strdup(""); - arr[n].path = cbm_heap_strdup(name); - arr[n].handler = cbm_heap_strdup(""); - - char *val; - val = extract_json_string_prop(props, "\"method\"", ST_METHOD_PROP_LEN); - if (val) { - safe_str_free(&arr[n].method); - arr[n].method = val; - } - val = extract_json_string_prop(props, "\"path\"", ST_PATH_PROP_LEN); - if (val) { - safe_str_free(&arr[n].path); - arr[n].path = val; - } - val = extract_json_string_prop(props, "\"handler\"", ST_HANDLER_PROP_LEN); - if (val) { - safe_str_free(&arr[n].handler); - arr[n].handler = val; - } - n++; - } - if (scan_rc22 != SQLITE_DONE) { /* SCANCHK:22:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - out->routes = arr; - out->route_count = n; - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - out->routes = arr; - out->route_count = n; - return CBM_STORE_OK; -} - -typedef struct { - int64_t source_id; - int64_t target_id; -} store_id_pair_t; - -/* The project's CALLS edges as (source, target) node ids: from the Zova edge - * list for a workspace store, otherwise from the edges table. */ -static int store_calls_pairs(cbm_store_t *s, const char *project, store_id_pair_t **out, - int *out_count) { - *out = NULL; - *out_count = 0; - int cap = 0; - int n = 0; - store_id_pair_t *pairs = NULL; - if (store_is_zova_workspace(s)) { - if (store_zova_edges(s) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - int t = store_zova_edge_type_find(s, "CALLS", strlen("CALLS")); - for (int i = 0; t >= 0 && i < s->zova_edge_count; i++) { - cap += s->zova_edges[i].type == t; - } - pairs = cap ? malloc((size_t)cap * sizeof(*pairs)) : NULL; - if (cap && !pairs) { - return CBM_STORE_ERR; - } - for (int i = 0; t >= 0 && i < s->zova_edge_count; i++) { - if (s->zova_edges[i].type == t) { - pairs[n++] = (store_id_pair_t){s->zova_edges[i].source_key, - s->zova_edges[i].target_key}; - } - } - *out = pairs; - *out_count = n; - return CBM_STORE_OK; - } - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, - "SELECT source_id, target_id FROM edges WHERE project=?1 " - "AND type='CALLS'", - CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "calls_pairs"); - return CBM_STORE_ERR; - } - bind_text(stmt, SKIP_ONE, project); - int rc; - while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n == cap) { - cap = cap ? cap * ST_GROWTH : CBM_SZ_256; - store_id_pair_t *grown = realloc(pairs, (size_t)cap * sizeof(*pairs)); - if (!grown) { - rc = SQLITE_NOMEM; - break; - } - pairs = grown; - } - pairs[n++] = (store_id_pair_t){sqlite3_column_int64(stmt, 0), - sqlite3_column_int64(stmt, SKIP_ONE)}; - } - sqlite3_finalize(stmt); - if (rc != SQLITE_DONE) { - free(pairs); - store_set_error(s, "calls_pairs"); - return CBM_STORE_ERR; - } - *out = pairs; - *out_count = n; - return CBM_STORE_OK; -} - -/* Hotspot candidates: non-test functions and methods. */ -#define ARCH_HOTSPOT_FILTER \ - "n.project=?1 AND n.label IN ('Function', 'Method') " \ - "AND (json_extract(n.properties, '$.is_test') IS NULL OR " \ - "json_extract(n.properties, '$.is_test') != 1) " \ - "AND n.file_path NOT LIKE '%test%'" - -/* Hotspots of a Zova workspace store: fan-in counted from the CALLS edges of - * the edge list, ties by node id, top 10. */ -static int arch_hotspots_zova(cbm_store_t *s, const char *project, const char *norm, - const char *like, cbm_architecture_info_t *out) { - store_id_pair_t *calls = NULL; - int call_count = 0; - if (store_calls_pairs(s, project, &calls, &call_count) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - int64_t *targets = malloc(((size_t)call_count + SKIP_ONE) * sizeof(*targets)); - if (!targets) { - free(calls); - return CBM_STORE_ERR; - } - for (int i = 0; i < call_count; i++) { - targets[i] = calls[i].target_id; - } - free(calls); - qsort(targets, (size_t)call_count, sizeof(*targets), store_id_compare); - - char sqlbuf[ST_SQL_BUF]; - snprintf(sqlbuf, sizeof(sqlbuf), - "SELECT n.id, n.name, n.qualified_name FROM nodes n WHERE %s%s ORDER BY n.id", - ARCH_HOTSPOT_FILTER, norm ? " AND (n.file_path = ?2 OR n.file_path LIKE ?3)" : ""); - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - free(targets); - store_set_error_sqlite(s, "arch_hotspots"); - return CBM_STORE_ERR; - } - bind_text(stmt, SKIP_ONE, project); - if (norm) { - arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like); - } - enum { HOTSPOT_TOP = 10 }; - cbm_hotspot_t *arr = calloc(HOTSPOT_TOP, sizeof(*arr)); - int n = 0; - int t = 0; - int rc = SQLITE_DONE; - while (arr && (rc = sqlite3_step(stmt)) == SQLITE_ROW) { - int64_t id = sqlite3_column_int64(stmt, 0); - while (t < call_count && targets[t] < id) { - t++; - } - int fan_in = 0; - while (t < call_count && targets[t] == id) { - fan_in++; - t++; - } - /* Rows come in id order, so an equal fan-in never displaces. */ - if (fan_in == 0 || (n == HOTSPOT_TOP && fan_in <= arr[n - SKIP_ONE].fan_in)) { - continue; - } - int at = n < HOTSPOT_TOP ? n++ : n - SKIP_ONE; - safe_str_free(&arr[at].name); - safe_str_free(&arr[at].qualified_name); - while (at > 0 && arr[at - SKIP_ONE].fan_in < fan_in) { - arr[at] = arr[at - SKIP_ONE]; - at--; - } - arr[at] = (cbm_hotspot_t){ - .name = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE)), - .qualified_name = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_2)), - .fan_in = fan_in, - }; - } - sqlite3_finalize(stmt); - free(targets); - if (!arr || rc != SQLITE_DONE) { - for (int i = 0; arr && i < n; i++) { - safe_str_free(&arr[i].name); - safe_str_free(&arr[i].qualified_name); - } - free(arr); - store_set_error(s, "arch_hotspots"); - return CBM_STORE_ERR; - } - out->hotspots = arr; - out->hotspot_count = n; - return CBM_STORE_OK; -} - -static int arch_hotspots(cbm_store_t *s, const char *project, const char *path, - cbm_architecture_info_t *out) { - char norm[CBM_SZ_512]; - char like[CBM_SZ_512]; - bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like)); - if (store_is_zova_workspace(s)) { - return arch_hotspots_zova(s, project, scoped ? norm : NULL, like, out); - } - char sqlbuf[ST_SQL_BUF]; - const char *base = "SELECT n.name, n.qualified_name, COUNT(*) as fan_in " - "FROM nodes n JOIN edges e ON e.target_id = n.id AND e.type = 'CALLS' " - "WHERE " ARCH_HOTSPOT_FILTER; - if (scoped) { - snprintf(sqlbuf, sizeof(sqlbuf), - "%s AND (n.file_path = ?2 OR n.file_path LIKE ?3) " - "GROUP BY n.id ORDER BY fan_in DESC LIMIT 10", - base); - } else { - snprintf(sqlbuf, sizeof(sqlbuf), "%s GROUP BY n.id ORDER BY fan_in DESC LIMIT 10", base); - } - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "arch_hotspots"); - return CBM_STORE_ERR; - } - bind_text(stmt, SKIP_ONE, project); - if (scoped) { - arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like); - } - - int cap = ST_INIT_CAP_8; - int n = 0; - cbm_hotspot_t *arr = calloc(cap, sizeof(cbm_hotspot_t)); - int scan_rc23; - while ((scan_rc23 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - arr = safe_realloc(arr, cap * sizeof(cbm_hotspot_t)); - } - arr[n].name = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, 0)); - arr[n].qualified_name = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE)); - arr[n].fan_in = sqlite3_column_int(stmt, CBM_SZ_2); - n++; - } - if (scan_rc23 != SQLITE_DONE) { /* SCANCHK:23:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - out->hotspots = arr; - out->hotspot_count = n; - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - out->hotspots = arr; - out->hotspot_count = n; - return CBM_STORE_OK; -} - -/* Look up package name for a node ID in the parallel arrays. nids must be - * sorted ascending (the node query orders by id) — a linear scan here is - * O(E×N) across the edge loop and spun for >10 minutes on Linux-kernel-sized - * graphs (~1.4M defs × ~1.4M CALLS edges). */ -static const char *lookup_pkg(const int64_t *nids, char **npkgs, int nn, int64_t id) { - int lo = 0; - int hi = nn - SKIP_ONE; - while (lo <= hi) { - int mid = lo + (hi - lo) / PAIR_LEN; - if (nids[mid] == id) { - return npkgs[mid]; - } - if (nids[mid] < id) { - lo = mid + SKIP_ONE; - } else { - hi = mid - SKIP_ONE; - } - } - return NULL; -} - -/* Accumulate a cross-package boundary into parallel arrays. */ -static void accum_boundary(const char *src_pkg, const char *tgt_pkg, char **bfroms, char **btos, - int *bcounts, int *bn, int bcap) { - int found = ST_FOUND; - for (int i = 0; i < *bn; i++) { - if (strcmp(bfroms[i], src_pkg) == 0 && strcmp(btos[i], tgt_pkg) == 0) { - found = i; - break; - } - } - if (found >= 0) { - bcounts[found]++; - } else if (*bn < bcap) { - bfroms[*bn] = cbm_heap_strdup(src_pkg); - btos[*bn] = cbm_heap_strdup(tgt_pkg); - bcounts[*bn] = SKIP_ONE; - (*bn)++; - } -} - -static int arch_boundaries(cbm_store_t *s, const char *project, const char *path, - cbm_cross_pkg_boundary_t **out_arr, int *out_count) { - char norm[CBM_SZ_512]; - char like[CBM_SZ_512]; - bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like)); - char nsqlbuf[ST_SQL_BUF]; - const char *nbase = - "SELECT id, qualified_name, file_path FROM nodes WHERE project=?1 AND label IN " - "('Function','Method','Class')"; - if (scoped) { - snprintf(nsqlbuf, sizeof(nsqlbuf), "%s%s ORDER BY id", nbase, arch_path_scope_sql()); - } else { - snprintf(nsqlbuf, sizeof(nsqlbuf), "%s ORDER BY id", nbase); - } - sqlite3_stmt *nstmt = NULL; - if (sqlite3_prepare_v2(s->db, nsqlbuf, CBM_NOT_FOUND, &nstmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "arch_boundaries_nodes"); - return CBM_STORE_ERR; - } - bind_text(nstmt, SKIP_ONE, project); - if (scoped) { - arch_bind_path_scope(nstmt, ST_COL_2, ST_COL_3, norm, like); - } - - int ncap = CBM_SZ_256; - int nn = 0; - int64_t *nids = malloc(ncap * sizeof(int64_t)); - char **npkgs = malloc(ncap * sizeof(char *)); - - int scan_rc24; - while ((scan_rc24 = sqlite3_step(nstmt)) == SQLITE_ROW) { - if (nn >= ncap) { - ncap *= ST_GROWTH; - nids = safe_realloc(nids, ncap * sizeof(int64_t)); - npkgs = safe_realloc(npkgs, ncap * sizeof(char *)); - } - int64_t nid = sqlite3_column_int64(nstmt, 0); - nids[nn] = nid; - const char *qn = (const char *)sqlite3_column_text(nstmt, SKIP_ONE); - npkgs[nn] = cbm_heap_strdup(cbm_qn_to_package(qn)); - nn++; - } - if (scan_rc24 != SQLITE_DONE) { /* SCANCHK:24:nstmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(nstmt); - for (int bi = 0; bi < nn; bi++) { - free(npkgs[bi]); - } - free(nids); - free(npkgs); - return CBM_STORE_ERR; - } - sqlite3_finalize(nstmt); - - /* Scan edges, count cross-package calls */ - store_id_pair_t *calls = NULL; - int call_count = 0; - if (store_calls_pairs(s, project, &calls, &call_count) != CBM_STORE_OK) { - for (int i = 0; i < nn; i++) { - free(npkgs[i]); - } - free(nids); - free(npkgs); - return CBM_STORE_ERR; - } - - int bcap = CBM_SZ_32; - int bn = 0; - char **bfroms = malloc(bcap * sizeof(char *)); - char **btos = malloc(bcap * sizeof(char *)); - int *bcounts = malloc(bcap * sizeof(int)); - - for (int e = 0; e < call_count; e++) { - int64_t src_id = calls[e].source_id; - int64_t tgt_id = calls[e].target_id; - const char *src_pkg = lookup_pkg(nids, npkgs, nn, src_id); - const char *tgt_pkg = lookup_pkg(nids, npkgs, nn, tgt_id); - if (!src_pkg || !tgt_pkg || !src_pkg[0] || !tgt_pkg[0] || strcmp(src_pkg, tgt_pkg) == 0) { - continue; - } - accum_boundary(src_pkg, tgt_pkg, bfroms, btos, bcounts, &bn, bcap); - } - free(calls); - for (int i = 0; i < nn; i++) { - free(npkgs[i]); - } - free(nids); - free(npkgs); - - /* Sort by count descending */ - for (int i = SKIP_ONE; i < bn; i++) { - int j = i; - while (j > 0 && bcounts[j] > bcounts[j - SKIP_ONE]) { - int tc = bcounts[j]; - bcounts[j] = bcounts[j - SKIP_ONE]; - bcounts[j - SKIP_ONE] = tc; - char *tf = bfroms[j]; - bfroms[j] = bfroms[j - SKIP_ONE]; - bfroms[j - SKIP_ONE] = tf; - char *tt = btos[j]; - btos[j] = btos[j - SKIP_ONE]; - btos[j - SKIP_ONE] = tt; - j--; - } - } - if (bn > CBM_DECIMAL_BASE) { - for (int i = ST_MAX_ITERATIONS; i < bn; i++) { - free(bfroms[i]); - free(btos[i]); - } - bn = ST_MAX_ITERATIONS; - } - - cbm_cross_pkg_boundary_t *result = - (bn > 0) ? calloc(bn, sizeof(cbm_cross_pkg_boundary_t)) : NULL; - for (int i = 0; i < bn; i++) { - result[i].from = bfroms[i]; - result[i].to = btos[i]; - result[i].call_count = bcounts[i]; - } - free(bfroms); - free(btos); - free(bcounts); - *out_arr = result; - *out_count = bn; - return CBM_STORE_OK; -} - -#define MAX_PREVIEW_NAMES 15 - -/* Fallback: derive packages from QN segments when no Package nodes exist. */ -static int arch_packages_from_qn(cbm_store_t *s, const char *project, const char *path, - cbm_package_summary_t **out_arr, int *out_count) { - char norm[CBM_SZ_512]; - char like[CBM_SZ_512]; - bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like)); - char qsqlbuf[ST_SQL_BUF]; - const char *qbase = "SELECT qualified_name FROM nodes WHERE project=?1 AND label IN " - "('Function','Method','Class')"; - if (scoped) { - snprintf(qsqlbuf, sizeof(qsqlbuf), "%s%s", qbase, arch_path_scope_sql()); - } else { - snprintf(qsqlbuf, sizeof(qsqlbuf), "%s", qbase); - } - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, qsqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "arch_packages_qn"); - return CBM_STORE_ERR; - } - bind_text(stmt, SKIP_ONE, project); - if (scoped) { - arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like); - } - - char *pnames[CBM_SZ_64]; - int pcounts[CBM_SZ_64]; - int np = 0; - int scan_rc26; - while ((scan_rc26 = sqlite3_step(stmt)) == SQLITE_ROW) { - const char *qn = (const char *)sqlite3_column_text(stmt, 0); - const char *pkg = cbm_qn_to_package(qn); - if (!pkg[0]) { - continue; - } - int found = ST_FOUND; - for (int i = 0; i < np; i++) { - if (strcmp(pnames[i], pkg) == 0) { - found = i; - break; - } - } - if (found >= 0) { - pcounts[found]++; - } else if (np < CBM_SZ_64) { - pnames[np] = cbm_heap_strdup(pkg); - pcounts[np] = SKIP_ONE; - np++; - } - } - if (scan_rc26 != SQLITE_DONE) { /* SCANCHK:26:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - for (int pi = 0; pi < np; pi++) { - free(pnames[pi]); - } - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - - /* Sort by count desc */ - for (int i = SKIP_ONE; i < np; i++) { - int j = i; - while (j > 0 && pcounts[j] > pcounts[j - SKIP_ONE]) { - int tc = pcounts[j]; - pcounts[j] = pcounts[j - SKIP_ONE]; - pcounts[j - SKIP_ONE] = tc; - char *tn = pnames[j]; - pnames[j] = pnames[j - SKIP_ONE]; - pnames[j - SKIP_ONE] = tn; - j--; - } - } - if (np > MAX_PREVIEW_NAMES) { - for (int i = MAX_PREVIEW_NAMES; i < np; i++) { - free(pnames[i]); - } - np = MAX_PREVIEW_NAMES; - } - - cbm_package_summary_t *arr = (np > 0) ? calloc(np, sizeof(cbm_package_summary_t)) : NULL; - for (int i = 0; i < np; i++) { - arr[i].name = pnames[i]; - arr[i].node_count = pcounts[i]; - } - *out_arr = arr; - *out_count = np; - return CBM_STORE_OK; -} - -static int arch_packages(cbm_store_t *s, const char *project, const char *path, - cbm_architecture_info_t *out) { - char norm[CBM_SZ_512]; - char like[CBM_SZ_512]; - bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like)); - char sqlbuf[ST_SQL_BUF]; - const char *base = "SELECT n.name, COUNT(*) as cnt FROM nodes n " - "WHERE n.project=?1 AND n.label='Package'"; - if (scoped) { - snprintf(sqlbuf, sizeof(sqlbuf), - "%s AND (n.file_path = ?2 OR n.file_path LIKE ?3) " - "GROUP BY n.name ORDER BY cnt DESC LIMIT 15", - base); - } else { - snprintf(sqlbuf, sizeof(sqlbuf), "%s GROUP BY n.name ORDER BY cnt DESC LIMIT 15", base); - } - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "arch_packages"); - return CBM_STORE_ERR; - } - bind_text(stmt, SKIP_ONE, project); - if (scoped) { - arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like); - } - - int cap = ST_INIT_CAP_16; - int n = 0; - cbm_package_summary_t *arr = calloc(cap, sizeof(cbm_package_summary_t)); - int scan_rc27; - while ((scan_rc27 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - arr = safe_realloc(arr, cap * sizeof(cbm_package_summary_t)); - } - arr[n].name = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, 0)); - arr[n].node_count = sqlite3_column_int(stmt, SKIP_ONE); - n++; - } - if (scan_rc27 != SQLITE_DONE) { /* SCANCHK:27:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - out->packages = arr; - out->package_count = n; - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - - /* Fallback: group by QN segment if no Package nodes */ - if (n == 0) { - free(arr); - int rc = arch_packages_from_qn(s, project, path, &arr, &n); - if (rc != CBM_STORE_OK) { - return rc; - } - } - - out->packages = arr; - out->package_count = n; - return CBM_STORE_OK; -} - -static void classify_layer(const char *pkg, int in, int out_deg, bool has_routes, - bool has_entry_points, const char **layer, const char **reason) { - static CBM_TLS char reason_buf[CBM_SZ_128]; - if (has_entry_points && out_deg > 0 && in == 0) { - *layer = "entry"; - *reason = "has entry points, only outbound calls"; - return; - } - if (has_routes) { - *layer = "api"; - *reason = "has HTTP route definitions"; - return; - } - if (in > out_deg && in > ST_MIN_INDEGREE) { - snprintf(reason_buf, sizeof(reason_buf), "high fan-in (%d in, %d out)", in, out_deg); - *layer = "core"; - *reason = reason_buf; - return; - } - if (out_deg == 0 && in > 0) { - *layer = "leaf"; - *reason = "only inbound calls, no outbound"; - return; - } - if (in == 0 && out_deg > 0) { - *layer = "entry"; - *reason = "only outbound calls"; - return; - } - snprintf(reason_buf, sizeof(reason_buf), "fan-in=%d, fan-out=%d", in, out_deg); - *layer = "internal"; - *reason = reason_buf; - (void)pkg; -} - -/* Find or insert a package name, returning its index. Returns -1 if full. */ -static int find_or_add_pkg(char **all_pkgs, int *npkgs, int max_pkgs, const char *pkg) { - for (int j = 0; j < *npkgs; j++) { - if (strcmp(all_pkgs[j], pkg) == 0) { - return j; - } - } - if (*npkgs < max_pkgs) { - int idx = *npkgs; - all_pkgs[idx] = cbm_heap_strdup(pkg); - (*npkgs)++; - return idx; - } - return CBM_NOT_FOUND; -} - -/* Check if a package name appears in an array. */ -static bool pkg_in_list(const char *pkg, char **list, int count) { - for (int j = 0; j < count; j++) { - if (strcmp(pkg, list[j]) == 0) { - return true; - } - } - return false; -} - -/* Collect package names from nodes matching a SQL query (must use ?1 = project). */ -static int collect_pkg_names(cbm_store_t *s, const char *sql, const char *project, const char *path, - char **pkgs, int max_pkgs) { - char norm[CBM_SZ_512]; - char like[CBM_SZ_512]; - bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like)); - char sqlbuf[ST_SQL_BUF]; - if (scoped) { - snprintf(sqlbuf, sizeof(sqlbuf), "%s%s", sql, arch_path_scope_sql()); - } else { - snprintf(sqlbuf, sizeof(sqlbuf), "%s", sql); - } - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) { - if (stmt) { - sqlite3_finalize(stmt); - } - return CBM_NOT_FOUND; - } - bind_text(stmt, SKIP_ONE, project); - if (scoped) { - arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like); - } - int count = 0; - while (sqlite3_step(stmt) == SQLITE_ROW && count < max_pkgs) { - const char *qn = (const char *)sqlite3_column_text(stmt, 0); - pkgs[count++] = cbm_heap_strdup(cbm_qn_to_package(qn)); - } - sqlite3_finalize(stmt); - return count; -} - -static int arch_layers(cbm_store_t *s, const char *project, const char *path, - cbm_architecture_info_t *out) { - /* Get boundaries for fan analysis */ - cbm_cross_pkg_boundary_t *boundaries = NULL; - int bcount = 0; - int rc = arch_boundaries(s, project, path, &boundaries, &bcount); - if (rc != CBM_STORE_OK) { - return rc; - } - - /* Collect route and entry point packages */ - char *route_pkgs[CBM_SZ_32]; - int nrpkgs = - collect_pkg_names(s, "SELECT qualified_name FROM nodes WHERE project=?1 AND label='Route'", - project, path, route_pkgs, CBM_SZ_32); - - char *entry_pkgs[CBM_SZ_32]; - int nepkgs = collect_pkg_names(s, - "SELECT qualified_name FROM nodes WHERE project=?1 AND " - "json_extract(properties, '$.is_entry_point') = 1", - project, path, entry_pkgs, CBM_SZ_32); - - /* Compute fan-in/out per package */ - char *all_pkgs[CBM_SZ_64]; - int fan_in[CBM_SZ_64]; - int fan_out[CBM_SZ_64]; - int npkgs = 0; - memset(fan_in, 0, sizeof(fan_in)); - memset(fan_out, 0, sizeof(fan_out)); - - for (int i = 0; i < bcount; i++) { - int fi = find_or_add_pkg(all_pkgs, &npkgs, ST_MAX_PKGS, boundaries[i].from); - if (fi >= 0) { - fan_out[fi] += boundaries[i].call_count; - } - int ti = find_or_add_pkg(all_pkgs, &npkgs, ST_MAX_PKGS, boundaries[i].to); - if (ti >= 0) { - fan_in[ti] += boundaries[i].call_count; - } - } - - /* Also include route/entry packages */ - for (int i = 0; i < nrpkgs; i++) { - find_or_add_pkg(all_pkgs, &npkgs, ST_MAX_PKGS, route_pkgs[i]); - } - for (int i = 0; i < nepkgs; i++) { - find_or_add_pkg(all_pkgs, &npkgs, ST_MAX_PKGS, entry_pkgs[i]); - } - - /* Classify each package */ - out->layers = (npkgs > 0) ? calloc(npkgs, sizeof(cbm_package_layer_t)) : NULL; - out->layer_count = npkgs; - for (int i = 0; i < npkgs; i++) { - bool has_route = pkg_in_list(all_pkgs[i], route_pkgs, nrpkgs); - bool has_entry = pkg_in_list(all_pkgs[i], entry_pkgs, nepkgs); - const char *layer; - const char *reason; - classify_layer(all_pkgs[i], fan_in[i], fan_out[i], has_route, has_entry, &layer, &reason); - out->layers[i].name = all_pkgs[i]; /* transfer ownership */ - out->layers[i].layer = cbm_heap_strdup(layer); - out->layers[i].reason = cbm_heap_strdup(reason); - } - - /* Sort layers by name */ - for (int i = SKIP_ONE; i < npkgs; i++) { - int j = i; - while (j > 0 && strcmp(out->layers[j].name, out->layers[j - SKIP_ONE].name) < 0) { - cbm_package_layer_t tmp = out->layers[j]; - out->layers[j] = out->layers[j - SKIP_ONE]; - out->layers[j - SKIP_ONE] = tmp; - j--; - } - } - - /* Cleanup */ - for (int i = 0; i < bcount; i++) { - safe_str_free(&boundaries[i].from); - safe_str_free(&boundaries[i].to); - } - free(boundaries); - for (int i = 0; i < nrpkgs; i++) { - free(route_pkgs[i]); - } - for (int i = 0; i < nepkgs; i++) { - free(entry_pkgs[i]); - } - - return CBM_STORE_OK; -} - -/* Add a child to a dir entry if not already present. */ -static void dir_add_child(char ***children, int *child_count, int *child_cap, const char *child) { - for (int k = 0; k < *child_count; k++) { - if (strcmp((*children)[k], child) == 0) { - return; - } - } - if (*child_count >= *child_cap) { - *child_cap = *child_cap ? *child_cap * PAIR_LEN : ST_INIT_CAP_4; - *children = realloc(*children, *child_cap * sizeof(char *)); - } - (*children)[(*child_count)++] = cbm_heap_strdup(child); -} - -/* Find or create a directory entry by path. Returns its index, or -1 if full. */ -static int dir_find_or_create(char **dir_paths, int *dir_child_counts, char ***dir_children, - int *dir_children_caps, int *dn, int dcap, const char *dir) { - for (int i = 0; i < *dn; i++) { - if (strcmp(dir_paths[i], dir) == 0) { - return i; - } - } - if (*dn < dcap) { - int idx = *dn; - dir_paths[idx] = cbm_heap_strdup(dir); - dir_child_counts[idx] = 0; - dir_children[idx] = NULL; - dir_children_caps[idx] = 0; - (*dn)++; - return idx; - } - return CBM_NOT_FOUND; -} - -/* Create a file tree entry by checking if a path is a file and counting dir children. */ -static cbm_file_tree_entry_t make_tree_entry(const char *path, char **files, int fn, - char **dir_paths, const int *dir_child_counts, - int dn) { - cbm_file_tree_entry_t e = {0}; - e.path = cbm_heap_strdup(path); - bool is_file = false; - for (int f = 0; f < fn; f++) { - if (strcmp(files[f], path) == 0) { - is_file = true; - break; - } - } - e.type = cbm_heap_strdup(is_file ? "file" : "dir"); - for (int d = 0; d < dn; d++) { - if (strcmp(dir_paths[d], path) == 0) { - e.children = dir_child_counts[d]; - break; - } - } - return e; -} - -/* Split a path by '/' into parts. Returns number of parts. */ -static int split_path_parts(const char *fp, char *buf, int buf_sz, char **parts, int max_parts) { - strncpy(buf, fp, buf_sz - SKIP_ONE); - buf[buf_sz - SKIP_ONE] = '\0'; - int nparts = 0; - char *p = buf; - parts[nparts++] = p; - while (*p && nparts < max_parts) { - if (*p == '/') { - *p = '\0'; - parts[nparts++] = p + SKIP_ONE; - } - p++; - } - return nparts; -} - -/* Register dir hierarchy for one file path. */ -static void arch_register_file_dirs(const char *fp, char **dir_paths, int *dir_child_counts, - char ***dir_children, int *dir_children_caps, int *dn, - int dcap) { - char tmp[CBM_SZ_512]; - char *parts[ST_SEARCH_MAX_BINDS]; - int nparts = split_path_parts(fp, tmp, (int)sizeof(tmp), parts, ST_SEARCH_MAX_BINDS); - - int ri = dir_find_or_create(dir_paths, dir_child_counts, dir_children, dir_children_caps, dn, - dcap, ""); - if (ri >= 0 && nparts > 0) { - dir_add_child(&dir_children[ri], &dir_child_counts[ri], &dir_children_caps[ri], parts[0]); - } - - for (int depth = 0; depth < nparts - SKIP_ONE && depth < ST_MAX_PATH_DEPTH; depth++) { - char dir[CBM_SZ_512] = ""; - for (int k = 0; k <= depth; k++) { - if (k > 0) { - strcat(dir, "/"); - } - strcat(dir, parts[k]); - } - const char *child = (depth + SKIP_ONE < nparts) ? parts[depth + SKIP_ONE] : NULL; - if (!child) { - continue; - } - int di = dir_find_or_create(dir_paths, dir_child_counts, dir_children, dir_children_caps, - dn, dcap, dir); - if (di >= 0) { - dir_add_child(&dir_children[di], &dir_child_counts[di], &dir_children_caps[di], child); - } - } -} - -/* Count the number of '/' in a string. */ -static int count_slashes(const char *s) { - int n = 0; - for (; *s; s++) { - if (*s == '/') { - n++; - } - } - return n; -} - -/* Push a tree entry, growing the array if needed. */ -static void push_tree_entry(cbm_file_tree_entry_t **entries, int *en, int *ecap, - cbm_file_tree_entry_t e) { - if (*en >= *ecap) { - *ecap *= ST_GROWTH; - *entries = safe_realloc(*entries, *ecap * sizeof(cbm_file_tree_entry_t)); - } - (*entries)[(*en)++] = e; -} - -/* Collect tree entries from dir arrays. */ -static void arch_collect_entries(char **dir_paths, int *dir_child_counts, char ***dir_children, - int dn, char **files, int fn, cbm_file_tree_entry_t **entries_out, - int *en_out) { - int ecap = CBM_SZ_64; - int en = 0; - cbm_file_tree_entry_t *entries = calloc(ecap, sizeof(cbm_file_tree_entry_t)); - - /* Root children */ - for (int i = 0; i < dn; i++) { - if (strcmp(dir_paths[i], "") != 0) { - continue; - } - for (int k = 0; k < dir_child_counts[i]; k++) { - push_tree_entry( - &entries, &en, &ecap, - make_tree_entry(dir_children[i][k], files, fn, dir_paths, dir_child_counts, dn)); - } - } - - /* Non-root dir children (depth < ST_COL_3) */ - for (int i = 0; i < dn; i++) { - if (strcmp(dir_paths[i], "") == 0 || count_slashes(dir_paths[i]) >= ST_MAX_PATH_DEPTH) { - continue; - } - for (int k = 0; k < dir_child_counts[i]; k++) { - char path[CBM_SZ_512]; - snprintf(path, sizeof(path), "%s/%s", dir_paths[i], dir_children[i][k]); - push_tree_entry(&entries, &en, &ecap, - make_tree_entry(path, files, fn, dir_paths, dir_child_counts, dn)); - } - } - - /* Sort by path */ - for (int i = SKIP_ONE; i < en; i++) { - int j = i; - while (j > 0 && strcmp(entries[j].path, entries[j - SKIP_ONE].path) < 0) { - cbm_file_tree_entry_t tmp = entries[j]; - entries[j] = entries[j - SKIP_ONE]; - entries[j - SKIP_ONE] = tmp; - j--; - } - } - - *entries_out = entries; - *en_out = en; -} - -/* Free dir arrays. */ -static void arch_free_dirs(char **dir_paths, int *dir_child_counts, char ***dir_children, - int *dir_children_caps, int dn, char **files, int fn) { - for (int i = 0; i < dn; i++) { - free(dir_paths[i]); - for (int k = 0; k < dir_child_counts[i]; k++) { - free(dir_children[i][k]); - } - free(dir_children[i]); - } - free(dir_paths); - free(dir_child_counts); - free(dir_children); - free(dir_children_caps); - for (int i = 0; i < fn; i++) { - free(files[i]); - } - free(files); -} - -static int arch_file_tree(cbm_store_t *s, const char *project, const char *path, - cbm_architecture_info_t *out) { - char norm[CBM_SZ_512]; - char like[CBM_SZ_512]; - bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like)); - char sqlbuf[ST_SQL_BUF]; - const char *base = "SELECT file_path FROM nodes WHERE project=?1 AND label='File'"; - if (scoped) { - snprintf(sqlbuf, sizeof(sqlbuf), "%s%s", base, arch_path_scope_sql()); - } else { - snprintf(sqlbuf, sizeof(sqlbuf), "%s", base); - } - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "arch_file_tree"); - return CBM_STORE_ERR; - } - bind_text(stmt, SKIP_ONE, project); - if (scoped) { - arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like); - } - - int fcap = CBM_SZ_32; - int fn = 0; - char **files = malloc(fcap * sizeof(char *)); - - int dcap = CBM_SZ_64; - int dn = 0; - char **dir_paths = calloc(dcap, sizeof(char *)); - int *dir_child_counts = calloc(dcap, sizeof(int)); - char ***dir_children = calloc(dcap, sizeof(char **)); - int *dir_children_caps = calloc(dcap, sizeof(int)); - - int scan_rc28; - while ((scan_rc28 = sqlite3_step(stmt)) == SQLITE_ROW) { - const char *fp = (const char *)sqlite3_column_text(stmt, 0); - if (!fp) { - continue; - } - if (fn >= fcap) { - fcap *= ST_GROWTH; - files = safe_realloc(files, fcap * sizeof(char *)); - } - files[fn++] = cbm_heap_strdup(fp); - arch_register_file_dirs(fp, dir_paths, dir_child_counts, dir_children, dir_children_caps, - &dn, dcap); - } - if (scan_rc28 != SQLITE_DONE) { /* SCANCHK:28:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - arch_free_dirs(dir_paths, dir_child_counts, dir_children, dir_children_caps, dn, files, fn); - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - - arch_collect_entries(dir_paths, dir_child_counts, dir_children, dn, files, fn, &out->file_tree, - &out->file_tree_count); - - arch_free_dirs(dir_paths, dir_child_counts, dir_children, dir_children_caps, dn, files, fn); - return CBM_STORE_OK; -} - -/* ── Louvain community detection ───────────────────────────────── */ - -/* Build deduplicated, normalized edge weight arrays from raw edges. - * Returns total number of unique edges in *out_wn. */ -/* Find the index of a node ID in the nodes array, or -1. */ -static int louvain_node_index(const int64_t *nodes, int n, int64_t id) { - for (int i = 0; i < n; i++) { - if (nodes[i] == id) { - return i; - } - } - return CBM_NOT_FOUND; -} - -static void louvain_build_weights(const int64_t *nodes, int n, const cbm_louvain_edge_t *edges, - int edge_count, int **out_wsi, int **out_wdi, double **out_ww, - int *out_wn) { - int wcap = edge_count > 0 ? edge_count : SKIP_ONE; - int wn = 0; - int *wsi = malloc(wcap * sizeof(int)); - int *wdi = malloc(wcap * sizeof(int)); - double *ww = malloc(wcap * sizeof(double)); - - for (int e = 0; e < edge_count; e++) { - int si = louvain_node_index(nodes, n, edges[e].src); - int di = louvain_node_index(nodes, n, edges[e].dst); - if (si < 0 || di < 0 || si == di) { - continue; - } - if (si > di) { - int tmp = si; - si = di; - di = tmp; - } - int found = ST_FOUND; - for (int i = 0; i < wn; i++) { - if (wsi[i] == si && wdi[i] == di) { - found = i; - break; - } - } - if (found >= 0) { - ww[found] += (double)SKIP_ONE; - } else { - if (wn >= wcap) { - wcap *= ST_GROWTH; - wsi = safe_realloc(wsi, wcap * sizeof(int)); - wdi = safe_realloc(wdi, wcap * sizeof(int)); - ww = safe_realloc(ww, wcap * sizeof(double)); - } - wsi[wn] = si; - wdi[wn] = di; - ww[wn] = (double)SKIP_ONE; - wn++; - } - } - *out_wsi = wsi; - *out_wdi = wdi; - *out_ww = ww; - *out_wn = wn; -} - -enum { LEIDEN_MAX_LEVELS = 64, LEIDEN_MOVE_PASS_CAP = 100 }; - -/* Weighted undirected graph in CSR form. Each undirected edge is stored as - * two directed entries; k[i] is the weighted degree of node i. Self-loops are - * never materialised — an aggregate node's intra-community weight is folded - * into k[i] (which is preserved across levels), while nbr/w hold only - * inter-community edges. The modularity gain therefore uses k[] for the - * null-model term and nbr/w for connectivity, so intra weight affects the - * degree but is never mistaken for an edge to another community. */ -typedef struct { - int n; - int *off; /* CSR offsets, length n + 1 */ - int *nbr; /* neighbour indices, length off[n] */ - double *w; /* edge weights aligned with nbr */ - double *k; /* weighted degree per node */ -} cbm_lg_t; - -static void lg_free(cbm_lg_t *g) { - free(g->off); - free(g->nbr); - free(g->w); - free(g->k); - g->off = NULL; - g->nbr = NULL; - g->w = NULL; - g->k = NULL; -} - -/* Build a CSR graph from a deduplicated undirected edge list. */ -static int lg_build(int n, const int *wsi, const int *wdi, const double *ww, int wn, - cbm_lg_t *out) { - int *off = calloc((size_t)n + 1, sizeof(int)); - double *k = calloc((size_t)n, sizeof(double)); - int *fill = malloc((size_t)n * sizeof(int)); - if (!off || !k || !fill) { - free(off); - free(k); - free(fill); - return CBM_NOT_FOUND; - } - for (int e = 0; e < wn; e++) { - off[wsi[e] + 1]++; - off[wdi[e] + 1]++; - } - for (int i = 0; i < n; i++) { - off[i + 1] += off[i]; - } - int total = off[n]; - int *nbr = malloc((size_t)(total > 0 ? total : 1) * sizeof(int)); - double *w = malloc((size_t)(total > 0 ? total : 1) * sizeof(double)); - if (!nbr || !w) { - free(off); - free(k); - free(fill); - free(nbr); - free(w); - return CBM_NOT_FOUND; - } - memcpy(fill, off, (size_t)n * sizeof(int)); - for (int e = 0; e < wn; e++) { - int a = wsi[e]; - int b = wdi[e]; - double we = ww[e]; - nbr[fill[a]] = b; - w[fill[a]] = we; - fill[a]++; - nbr[fill[b]] = a; - w[fill[b]] = we; - fill[b]++; - k[a] += we; - k[b] += we; - } - free(fill); - out->n = n; - out->off = off; - out->nbr = nbr; - out->w = w; - out->k = k; - return CBM_STORE_OK; -} - -/* Local-moving phase: greedily move each node to the neighbouring community - * with the highest modularity gain, using a work queue seeded with every node - * and re-queueing only the neighbours of a node that actually moved. Mutates - * comm[] in place. */ -static void leiden_move(const cbm_lg_t *g, int *comm, double gamma, double twom) { - int n = g->n; - double *stot = calloc((size_t)n, sizeof(double)); - double *acc = calloc((size_t)n, sizeof(double)); - int *queue = malloc((size_t)n * sizeof(int)); - int *dirty = malloc((size_t)n * sizeof(int)); - bool *inq = calloc((size_t)n, sizeof(bool)); - if (!stot || !acc || !queue || !dirty || !inq) { - free(stot); - free(acc); - free(queue); - free(dirty); - free(inq); - return; - } - for (int i = 0; i < n; i++) { - stot[comm[i]] += g->k[i]; - queue[i] = i; - inq[i] = true; - } - int qhead = 0; - int qcount = n; - long cap = (long)n * LEIDEN_MOVE_PASS_CAP + LEIDEN_MAX_LEVELS; - while (qcount > 0 && cap-- > 0) { - int v = queue[qhead]; - qhead = (qhead + 1) % n; - qcount--; - inq[v] = false; - int cv = comm[v]; - int ndirty = 0; - for (int e = g->off[v]; e < g->off[v + 1]; e++) { - int u = g->nbr[e]; - if (u == v) { - continue; - } - int cu = comm[u]; - if (acc[cu] == 0.0) { - dirty[ndirty++] = cu; - } - acc[cu] += g->w[e]; - } - stot[cv] -= g->k[v]; - double kv = g->k[v]; - int best_c = cv; - double best_gain = acc[cv] - gamma * kv * stot[cv] / twom; - for (int d = 0; d < ndirty; d++) { - int c = dirty[d]; - double gain = acc[c] - gamma * kv * stot[c] / twom; - if (gain > best_gain) { - best_gain = gain; - best_c = c; - } - } - stot[best_c] += kv; - comm[v] = best_c; - if (best_c != cv) { - for (int e = g->off[v]; e < g->off[v + 1]; e++) { - int u = g->nbr[e]; - if (comm[u] != best_c && !inq[u] && qcount < n) { - queue[(qhead + qcount) % n] = u; - qcount++; - inq[u] = true; - } - } - } - for (int d = 0; d < ndirty; d++) { - acc[dirty[d]] = 0.0; - } - } - free(stot); - free(acc); - free(queue); - free(dirty); - free(inq); -} - -/* Compact community labels in comm[] to the dense range [0, returned count). */ -static int leiden_relabel(int *comm, int n) { - int *map = malloc((size_t)n * sizeof(int)); - if (!map) { - return n; - } - for (int i = 0; i < n; i++) { - map[i] = CBM_NOT_FOUND; - } - int next = 0; - for (int i = 0; i < n; i++) { - int c = comm[i]; - if (map[c] == CBM_NOT_FOUND) { - map[c] = next++; - } - comm[i] = map[c]; - } - free(map); - return next; -} - -/* Refinement phase: within each move-phase community, merge singleton nodes - * into the best connected sub-community (positive modularity gain, edge must - * exist). This re-derives communities bottom-up so each one is guaranteed - * internally connected — the defect single-level Louvain suffers from, which - * fragments the graph into hundreds of tiny noisy clusters. Writes - * sub-community labels into refined[] and returns their count. */ -static int leiden_refine(const cbm_lg_t *g, const int *comm, double gamma, double twom, - int *refined) { - int n = g->n; - double *stot = calloc((size_t)n, sizeof(double)); - double *acc = calloc((size_t)n, sizeof(double)); - int *rsize = malloc((size_t)n * sizeof(int)); - int *dirty = malloc((size_t)n * sizeof(int)); - if (!stot || !acc || !rsize || !dirty) { - free(stot); - free(acc); - free(rsize); - free(dirty); - for (int i = 0; i < n; i++) { - refined[i] = i; - } - return leiden_relabel(refined, n); - } - for (int i = 0; i < n; i++) { - refined[i] = i; - stot[i] = g->k[i]; - rsize[i] = 1; - } - for (int v = 0; v < n; v++) { - if (rsize[refined[v]] != 1) { - continue; /* only singletons merge, per the refinement rule */ - } - int cv = comm[v]; - int ndirty = 0; - for (int e = g->off[v]; e < g->off[v + 1]; e++) { - int u = g->nbr[e]; - if (u == v || comm[u] != cv) { - continue; /* stay within the move-phase community */ - } - int ru = refined[u]; - if (acc[ru] == 0.0) { - dirty[ndirty++] = ru; - } - acc[ru] += g->w[e]; - } - int rv = refined[v]; - double kv = g->k[v]; - stot[rv] -= kv; - int best_r = rv; - double best_gain = 0.0; - for (int d = 0; d < ndirty; d++) { - int r = dirty[d]; - if (r == rv) { - continue; - } - double gain = acc[r] - gamma * kv * stot[r] / twom; - if (gain > best_gain) { - best_gain = gain; - best_r = r; - } - } - if (best_r != rv) { - refined[v] = best_r; - stot[best_r] += kv; - rsize[best_r]++; - rsize[rv]--; - } else { - stot[rv] += kv; - } - for (int d = 0; d < ndirty; d++) { - acc[dirty[d]] = 0.0; - } - } - free(stot); - free(acc); - free(rsize); - free(dirty); - return leiden_relabel(refined, n); -} - -/* Aggregation phase: collapse each refined sub-community into a single node. - * Builds the coarser graph in *out and records, for each aggregate node, the - * move-phase community it belonged to in seed[] so the next level starts from - * the coarse structure rather than from singletons. */ -static int leiden_aggregate(const cbm_lg_t *g, const int *refined, int r_count, const int *comm, - cbm_lg_t *out, int *seed) { - int n = g->n; - double *k2 = calloc((size_t)r_count, sizeof(double)); - int *gcount = calloc((size_t)r_count, sizeof(int)); - int *gstart = malloc(((size_t)r_count + 1) * sizeof(int)); - int *members = malloc((size_t)n * sizeof(int)); - int *fill = malloc((size_t)r_count * sizeof(int)); - double *acc = calloc((size_t)r_count, sizeof(double)); - int *dirty = malloc((size_t)r_count * sizeof(int)); - int *off2 = malloc(((size_t)r_count + 1) * sizeof(int)); - if (!k2 || !gcount || !gstart || !members || !fill || !acc || !dirty || !off2) { - free(k2); - free(gcount); - free(gstart); - free(members); - free(fill); - free(acc); - free(dirty); - free(off2); - return CBM_NOT_FOUND; - } - for (int r = 0; r < r_count; r++) { - seed[r] = CBM_NOT_FOUND; - } - for (int i = 0; i < n; i++) { - int r = refined[i]; - k2[r] += g->k[i]; - gcount[r]++; - if (seed[r] == CBM_NOT_FOUND) { - seed[r] = comm[i]; - } - } - gstart[0] = 0; - for (int r = 0; r < r_count; r++) { - gstart[r + 1] = gstart[r] + gcount[r]; - fill[r] = gstart[r]; - } - for (int i = 0; i < n; i++) { - members[fill[refined[i]]++] = i; - } - /* Pass 1: count distinct inter-community neighbours per aggregate node. */ - off2[0] = 0; - for (int r = 0; r < r_count; r++) { - int nd = 0; - for (int m = gstart[r]; m < gstart[r + 1]; m++) { - int i = members[m]; - for (int e = g->off[i]; e < g->off[i + 1]; e++) { - int rb = refined[g->nbr[e]]; - if (rb == r || acc[rb] != 0.0) { - continue; - } - acc[rb] = 1.0; - dirty[nd++] = rb; - } - } - off2[r + 1] = off2[r] + nd; - for (int d = 0; d < nd; d++) { - acc[dirty[d]] = 0.0; - } - } - int total = off2[r_count]; - int *nbr2 = malloc((size_t)(total > 0 ? total : 1) * sizeof(int)); - double *w2 = malloc((size_t)(total > 0 ? total : 1) * sizeof(double)); - if (!nbr2 || !w2) { - free(k2); - free(gcount); - free(gstart); - free(members); - free(fill); - free(acc); - free(dirty); - free(off2); - free(nbr2); - free(w2); - return CBM_NOT_FOUND; - } - /* Pass 2: accumulate inter-community edge weights. */ - for (int r = 0; r < r_count; r++) { - int nd = 0; - for (int m = gstart[r]; m < gstart[r + 1]; m++) { - int i = members[m]; - for (int e = g->off[i]; e < g->off[i + 1]; e++) { - int rb = refined[g->nbr[e]]; - if (rb == r) { - continue; - } - if (acc[rb] == 0.0) { - dirty[nd++] = rb; - } - acc[rb] += g->w[e]; - } - } - int base = off2[r]; - for (int d = 0; d < nd; d++) { - nbr2[base + d] = dirty[d]; - w2[base + d] = acc[dirty[d]]; - acc[dirty[d]] = 0.0; - } - } - free(gcount); - free(gstart); - free(members); - free(fill); - free(acc); - free(dirty); - out->n = r_count; - out->off = off2; - out->nbr = nbr2; - out->w = w2; - out->k = k2; - return CBM_STORE_OK; -} - -int cbm_leiden(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *edges, - int edge_count, double resolution, cbm_louvain_result_t **out, int *out_count) { - if (node_count <= 0) { - *out = NULL; - *out_count = 0; - return CBM_STORE_OK; - } - int n = node_count; - double gamma = resolution > 0.0 ? resolution : 1.0; - - cbm_louvain_result_t *result = malloc((size_t)n * sizeof(*result)); - if (!result) { - return CBM_NOT_FOUND; - } - for (int i = 0; i < n; i++) { - result[i].node_id = nodes[i]; - result[i].community = i; - } - - /* Build deduplicated undirected edge weights, then a CSR graph. */ - int *wsi; - int *wdi; - double *ww; - int wn; - louvain_build_weights(nodes, n, edges, edge_count, &wsi, &wdi, &ww, &wn); - cbm_lg_t g; - int built = (wn > 0) ? lg_build(n, wsi, wdi, ww, wn, &g) : CBM_NOT_FOUND; - free(wsi); - free(wdi); - free(ww); - if (built != CBM_STORE_OK) { - /* No edges (or allocation failure): every node is its own community. */ - *out = result; - *out_count = n; - return CBM_STORE_OK; - } - - double twom = 0.0; - for (int i = 0; i < n; i++) { - twom += g.k[i]; - } - - int *orig = malloc((size_t)n * sizeof(int)); /* original node -> current graph node */ - int *comm = malloc((size_t)n * sizeof(int)); - if (twom <= 0.0 || !orig || !comm) { - free(orig); - free(comm); - lg_free(&g); - *out = result; - *out_count = n; - return CBM_STORE_OK; - } - for (int i = 0; i < n; i++) { - orig[i] = i; - comm[i] = i; - } - - for (int level = 0; level < LEIDEN_MAX_LEVELS; level++) { - leiden_move(&g, comm, gamma, twom); - int c_count = leiden_relabel(comm, g.n); - if (c_count >= g.n) { - break; /* every node already isolated — nothing to coarsen */ - } - int *refined = malloc((size_t)g.n * sizeof(int)); - if (!refined) { - break; - } - int r_count = leiden_refine(&g, comm, gamma, twom, refined); - if (r_count >= g.n) { - free(refined); - break; /* refinement cannot reduce the graph further */ - } - for (int i = 0; i < n; i++) { - orig[i] = refined[orig[i]]; - } - cbm_lg_t g2; - int *seed = malloc((size_t)r_count * sizeof(int)); - if (!seed || leiden_aggregate(&g, refined, r_count, comm, &g2, seed) != CBM_STORE_OK) { - free(seed); - free(refined); - break; - } - free(refined); - lg_free(&g); - g = g2; - free(comm); - comm = seed; - } - - for (int i = 0; i < n; i++) { - result[i].community = comm[orig[i]]; - } - free(comm); - free(orig); - lg_free(&g); - *out = result; - *out_count = n; - return CBM_STORE_OK; -} - -int cbm_louvain(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *edges, - int edge_count, cbm_louvain_result_t **out, int *out_count) { - return cbm_leiden(nodes, node_count, edges, edge_count, 1.0, out, out_count); -} - -/* ── Architecture: community clusters via Leiden ───────────────── */ - -enum { - CBM_CLUSTER_TOP_N = 12, /* report at most this many clusters */ - CBM_CLUSTER_MAX_TOPNODES = 5, /* representative node names per cluster */ - CBM_CLUSTER_MAX_PKGS = 5, /* packages listed per cluster */ - CBM_CLUSTER_MIN_MEMBERS = 2, /* skip singletons */ - CBM_CLUSTER_NODE_CAP = 8000 /* bound the work for very large graphs */ -}; - -static int cluster_id_cmp(const void *key, const void *el) { - int64_t k = *(const int64_t *)key; - int64_t e = *(const int64_t *)el; - return (k > e) - (k < e); -} - -/* Index of node id within the id-sorted ids[], or CBM_NOT_FOUND. */ -static int cluster_id_index(const int64_t *ids, int n, int64_t id) { - const int64_t *hit = bsearch(&id, ids, (size_t)n, sizeof(int64_t), cluster_id_cmp); - return hit ? (int)(hit - ids) : CBM_NOT_FOUND; -} - -/* Append `pkg` to a distinct package list (with a per-package count). */ -static void cluster_add_pkg(const char **pkgs, int *counts, int *count, int cap, const char *pkg) { - if (!pkg || !pkg[0]) { - return; - } - for (int i = 0; i < *count; i++) { - if (strcmp(pkgs[i], pkg) == 0) { - counts[i]++; - return; - } - } - if (*count < cap) { - pkgs[*count] = cbm_heap_strdup(pkg); - counts[*count] = 1; - (*count)++; - } -} - -/* Build the cluster_info for one community c into *ci. */ -static void cluster_build_one(cbm_cluster_info_t *ci, int c, int n, const int *comm, - const int *degree, const char **names, const char **qns, int members, - double cohesion) { - memset(ci, 0, sizeof(*ci)); - ci->id = c; - ci->members = members; - ci->cohesion = cohesion; - - /* Top nodes by degree. */ - int top_idx[CBM_CLUSTER_MAX_TOPNODES]; - int top_deg[CBM_CLUSTER_MAX_TOPNODES]; - int tn = 0; - for (int i = 0; i < n; i++) { - if (comm[i] != c) { - continue; - } - int d = degree[i]; - int pos = tn; - while (pos > 0 && top_deg[pos - 1] < d) { - pos--; - } - if (pos < CBM_CLUSTER_MAX_TOPNODES) { - int last = (tn < CBM_CLUSTER_MAX_TOPNODES) ? tn : CBM_CLUSTER_MAX_TOPNODES - 1; - for (int k = last; k > pos; k--) { - top_idx[k] = top_idx[k - 1]; - top_deg[k] = top_deg[k - 1]; - } - top_idx[pos] = i; - top_deg[pos] = d; - if (tn < CBM_CLUSTER_MAX_TOPNODES) { - tn++; - } - } - } - if (tn > 0) { - ci->top_nodes = malloc((size_t)tn * sizeof(char *)); - for (int i = 0; i < tn; i++) { - ci->top_nodes[i] = cbm_heap_strdup(names[top_idx[i]]); - } - ci->top_node_count = tn; - } - - /* Distinct packages (+ dominant one as the label). */ - const char *pkgs[CBM_CLUSTER_MAX_PKGS]; - int pkg_counts[CBM_CLUSTER_MAX_PKGS]; - int pc = 0; - for (int i = 0; i < n; i++) { - if (comm[i] == c) { - cluster_add_pkg(pkgs, pkg_counts, &pc, CBM_CLUSTER_MAX_PKGS, - cbm_qn_to_top_package(qns[i])); - } - } - if (pc > 0) { - ci->packages = malloc((size_t)pc * sizeof(char *)); - int best = 0; - for (int i = 0; i < pc; i++) { - ci->packages[i] = cbm_heap_strdup(pkgs[i]); - if (pkg_counts[i] > pkg_counts[best]) { - best = i; - } - } - ci->package_count = pc; - ci->label = cbm_heap_strdup(pkgs[best]); - for (int i = 0; i < pc; i++) { - safe_str_free(&pkgs[i]); - } - } - if (!ci->label) { - ci->label = cbm_heap_strdup(ci->top_node_count > 0 ? ci->top_nodes[0] : "cluster"); - } - - ci->edge_types = malloc(sizeof(char *)); - ci->edge_types[0] = cbm_heap_strdup("CALLS"); - ci->edge_type_count = 1; -} - -/* Comparator for sorting community indices by descending member count. */ -typedef struct { - int comm; - int members; -} cluster_rank_t; -static int cluster_rank_cmp(const void *a, const void *b) { - const cluster_rank_t *ca = a; - const cluster_rank_t *cb = b; - return cb->members - ca->members; -} - -static int arch_clusters(cbm_store_t *s, const char *project, const char *path, - cbm_architecture_info_t *out) { - char norm[CBM_SZ_512]; - char like[CBM_SZ_512]; - bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like)); - char nsqlbuf[ST_SQL_BUF]; - const char *nbase = "SELECT id, name, qualified_name, file_path FROM nodes " - "WHERE project=?1 AND label IN ('Function','Method','Class')"; - if (scoped) { - snprintf(nsqlbuf, sizeof(nsqlbuf), "%s%s ORDER BY id LIMIT ?4", nbase, - arch_path_scope_sql()); - } else { - snprintf(nsqlbuf, sizeof(nsqlbuf), "%s ORDER BY id LIMIT ?2", nbase); - } - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(s->db, nsqlbuf, CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) { - return CBM_STORE_OK; /* clusters are best-effort */ - } - bind_text(st, SKIP_ONE, project); - if (scoped) { - arch_bind_path_scope(st, ST_COL_2, ST_COL_3, norm, like); - sqlite3_bind_int(st, ST_COL_4, CBM_CLUSTER_NODE_CAP); - } else { - sqlite3_bind_int(st, CBM_SZ_2, CBM_CLUSTER_NODE_CAP); - } - int cap = ST_INIT_CAP_8; - int n = 0; - int64_t *ids = malloc((size_t)cap * sizeof(int64_t)); - const char **names = malloc((size_t)cap * sizeof(char *)); - const char **qns = malloc((size_t)cap * sizeof(char *)); - int scan_rc29; - while ((scan_rc29 = sqlite3_step(st)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - ids = safe_realloc(ids, (size_t)cap * sizeof(int64_t)); - names = safe_realloc(names, (size_t)cap * sizeof(char *)); - qns = safe_realloc(qns, (size_t)cap * sizeof(char *)); - } - ids[n] = sqlite3_column_int64(st, 0); - names[n] = cbm_heap_strdup((const char *)sqlite3_column_text(st, SKIP_ONE)); - qns[n] = cbm_heap_strdup((const char *)sqlite3_column_text(st, CBM_SZ_2)); - n++; - } - if (scan_rc29 != SQLITE_DONE) { /* SCANCHK:29:st */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(st); - for (int ci = 0; ci < n; ci++) { - safe_str_free(&names[ci]); - safe_str_free(&qns[ci]); - } - free(ids); - free((void *)names); - free((void *)qns); - return CBM_STORE_ERR; - } - sqlite3_finalize(st); - if (n < CBM_CLUSTER_MIN_MEMBERS) { - for (int i = 0; i < n; i++) { - safe_str_free(&names[i]); - safe_str_free(&qns[i]); - } - free(ids); - free(names); - free(qns); - return CBM_STORE_OK; - } - - /* 2. Load CALLS edges with both endpoints in the node set (store indices). */ - cbm_louvain_edge_t *edges = malloc((size_t)n * sizeof(cbm_louvain_edge_t)); - int *esrc = malloc((size_t)n * sizeof(int)); - int *edst = malloc((size_t)n * sizeof(int)); - int *degree = calloc((size_t)n, sizeof(int)); - int ne = 0; - store_id_pair_t *calls = NULL; - int call_count = 0; - if (store_calls_pairs(s, project, &calls, &call_count) == CBM_STORE_OK) { - int ecap = n; - for (int c = 0; c < call_count; c++) { - int si = cluster_id_index(ids, n, calls[c].source_id); - int ti = cluster_id_index(ids, n, calls[c].target_id); - if (si < 0 || ti < 0 || si == ti) { - continue; - } - if (ne >= ecap) { - ecap *= ST_GROWTH; - edges = safe_realloc(edges, (size_t)ecap * sizeof(cbm_louvain_edge_t)); - esrc = safe_realloc(esrc, (size_t)ecap * sizeof(int)); - edst = safe_realloc(edst, (size_t)ecap * sizeof(int)); - } - edges[ne].src = ids[si]; - edges[ne].dst = ids[ti]; - esrc[ne] = si; - edst[ne] = ti; - degree[si]++; - degree[ti]++; - ne++; - } - free(calls); - } - - /* 3. Community detection. */ - cbm_louvain_result_t *res = NULL; - int rn = 0; - int *comm = NULL; - int C = 0; - if (cbm_leiden(ids, n, edges, ne, 1.0, &res, &rn) == CBM_STORE_OK && res && rn == n) { - comm = malloc((size_t)n * sizeof(int)); - for (int i = 0; i < n; i++) { - comm[i] = res[i].community; - if (comm[i] + 1 > C) { - C = comm[i] + 1; - } - } - } - free(res); - - if (comm && C > 0) { - /* 4. Members + cohesion (internal vs boundary edges) per community. */ - int *members = calloc((size_t)C, sizeof(int)); - int *internal = calloc((size_t)C, sizeof(int)); - int *boundary = calloc((size_t)C, sizeof(int)); - for (int i = 0; i < n; i++) { - members[comm[i]]++; - } - for (int e = 0; e < ne; e++) { - int cs = comm[esrc[e]]; - int cd = comm[edst[e]]; - if (cs == cd) { - internal[cs]++; - } else { - boundary[cs]++; - boundary[cd]++; - } - } - - /* 5. Rank communities by size, take the top N non-singletons. */ - cluster_rank_t *rank = malloc((size_t)C * sizeof(cluster_rank_t)); - for (int c = 0; c < C; c++) { - rank[c] = (cluster_rank_t){c, members[c]}; - } - if (C > 1) { - qsort(rank, (size_t)C, sizeof(cluster_rank_t), cluster_rank_cmp); - } - - cbm_cluster_info_t *clusters = - malloc((size_t)CBM_CLUSTER_TOP_N * sizeof(cbm_cluster_info_t)); - int cc = 0; - for (int r = 0; r < C && cc < CBM_CLUSTER_TOP_N; r++) { - int c = rank[r].comm; - if (members[c] < CBM_CLUSTER_MIN_MEMBERS) { - break; /* sorted desc — the rest are singletons too */ - } - double denom = internal[c] + boundary[c]; - double cohesion = denom > 0 ? (double)internal[c] / denom : 0.0; - cluster_build_one(&clusters[cc], c, n, comm, degree, names, qns, members[c], cohesion); - cc++; - } - out->clusters = clusters; - out->cluster_count = cc; - - free(members); - free(internal); - free(boundary); - free(rank); - } - - free(comm); - for (int i = 0; i < n; i++) { - safe_str_free(&names[i]); - safe_str_free(&qns[i]); - } - free(ids); - free(names); - free(qns); - free(edges); - free(esrc); - free(edst); - free(degree); - return CBM_STORE_OK; -} - -/* ── GetArchitecture dispatch ──────────────────────────────────── */ - -/* "overview" = compact architecture summary: every aspect EXCEPT the large - * per-file listing (file_tree), which alone dominates the payload on real - * repos and can push the MCP response past the output cap. Declared in - * store.h and shared with aspect_wanted in src/mcp/mcp.c so the store-side - * DB gate and the MCP-side serialization gate cannot drift. */ -bool cbm_store_arch_aspect_in_overview(const char *name) { - return strcmp(name, "file_tree") != 0; -} - -static bool want_aspect(const char **aspects, int aspect_count, const char *name) { - if (!aspects || aspect_count == 0) { - return true; - } - for (int i = 0; i < aspect_count; i++) { - if (strcmp(aspects[i], "all") == 0) { - return true; - } - if (strcmp(aspects[i], "overview") == 0 && cbm_store_arch_aspect_in_overview(name)) { - return true; - } - if (strcmp(aspects[i], name) == 0) { - return true; - } - } - return false; -} - -int cbm_store_get_architecture(cbm_store_t *s, const char *project, const char *path, - const char **aspects, int aspect_count, - cbm_architecture_info_t *out) { - memset(out, 0, sizeof(*out)); - int rc = store_legacy_scope(s, &project); - if (rc != CBM_STORE_OK) { - return rc; - } - - if (want_aspect(aspects, aspect_count, "languages")) { - rc = arch_languages(s, project, path, out); - if (rc != CBM_STORE_OK) { - return rc; - } - } - if (want_aspect(aspects, aspect_count, "packages")) { - rc = arch_packages(s, project, path, out); - if (rc != CBM_STORE_OK) { - return rc; - } - } - if (want_aspect(aspects, aspect_count, "entry_points")) { - rc = arch_entry_points(s, project, path, out); - if (rc != CBM_STORE_OK) { - return rc; - } - } - if (want_aspect(aspects, aspect_count, "routes")) { - rc = arch_routes(s, project, path, out); - if (rc != CBM_STORE_OK) { - return rc; - } - } - if (want_aspect(aspects, aspect_count, "hotspots")) { - rc = arch_hotspots(s, project, path, out); - if (rc != CBM_STORE_OK) { - return rc; - } - } - if (want_aspect(aspects, aspect_count, "boundaries")) { - cbm_cross_pkg_boundary_t *barr = NULL; - int bcount = 0; - rc = arch_boundaries(s, project, path, &barr, &bcount); - if (rc != CBM_STORE_OK) { - return rc; - } - out->boundaries = barr; - out->boundary_count = bcount; - } - if (want_aspect(aspects, aspect_count, "layers")) { - rc = arch_layers(s, project, path, out); - if (rc != CBM_STORE_OK) { - return rc; - } - } - if (want_aspect(aspects, aspect_count, "file_tree")) { - rc = arch_file_tree(s, project, path, out); - if (rc != CBM_STORE_OK) { - return rc; - } - } - if (want_aspect(aspects, aspect_count, "clusters")) { - rc = arch_clusters(s, project, path, out); - if (rc != CBM_STORE_OK) { - return rc; - } - } - - return CBM_STORE_OK; -} - -void cbm_store_architecture_free(cbm_architecture_info_t *out) { - if (!out) { - return; - } - for (int i = 0; i < out->language_count; i++) { - safe_str_free(&out->languages[i].language); - } - free(out->languages); - for (int i = 0; i < out->package_count; i++) { - safe_str_free(&out->packages[i].name); - } - free(out->packages); - for (int i = 0; i < out->entry_point_count; i++) { - safe_str_free(&out->entry_points[i].name); - safe_str_free(&out->entry_points[i].qualified_name); - safe_str_free(&out->entry_points[i].file); - } - free(out->entry_points); - for (int i = 0; i < out->route_count; i++) { - safe_str_free(&out->routes[i].method); - safe_str_free(&out->routes[i].path); - safe_str_free(&out->routes[i].handler); - } - free(out->routes); - for (int i = 0; i < out->hotspot_count; i++) { - safe_str_free(&out->hotspots[i].name); - safe_str_free(&out->hotspots[i].qualified_name); - } - free(out->hotspots); - for (int i = 0; i < out->boundary_count; i++) { - safe_str_free(&out->boundaries[i].from); - safe_str_free(&out->boundaries[i].to); - } - free(out->boundaries); - for (int i = 0; i < out->service_count; i++) { - safe_str_free(&out->services[i].from); - safe_str_free(&out->services[i].to); - safe_str_free(&out->services[i].type); - } - free(out->services); - for (int i = 0; i < out->layer_count; i++) { - safe_str_free(&out->layers[i].name); - safe_str_free(&out->layers[i].layer); - safe_str_free(&out->layers[i].reason); - } - free(out->layers); - for (int i = 0; i < out->cluster_count; i++) { - safe_str_free(&out->clusters[i].label); - for (int j = 0; j < out->clusters[i].top_node_count; j++) { - safe_str_free(&out->clusters[i].top_nodes[j]); - } - free(out->clusters[i].top_nodes); - for (int j = 0; j < out->clusters[i].package_count; j++) { - safe_str_free(&out->clusters[i].packages[j]); - } - free(out->clusters[i].packages); - for (int j = 0; j < out->clusters[i].edge_type_count; j++) { - safe_str_free(&out->clusters[i].edge_types[j]); - } - free(out->clusters[i].edge_types); - } - free(out->clusters); - for (int i = 0; i < out->file_tree_count; i++) { - safe_str_free(&out->file_tree[i].path); - safe_str_free(&out->file_tree[i].type); - } - free(out->file_tree); - memset(out, 0, sizeof(*out)); -} - -/* ── Project summaries ─────────────────────────────────────────── */ - -int cbm_store_project_summary_export(cbm_store_t *s, const char *project, - cbm_project_summary_export_t *out) { - if (!s || !project || !out) return CBM_STORE_ERR; - memset(out, 0, sizeof(*out)); - sqlite3_stmt *stmt = NULL; - const char *sql = "SELECT summary, source_hash, created_at, updated_at " - "FROM project_summaries WHERE project=?1"; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "project_summary_export_prepare"); - return CBM_STORE_ERR; - } - bind_text(stmt, SKIP_ONE, project); - int step = sqlite3_step(stmt); - if (step == SQLITE_DONE) { - sqlite3_finalize(stmt); - return CBM_STORE_NOT_FOUND; - } - if (step != SQLITE_ROW) { - sqlite3_finalize(stmt); - store_set_error_sqlite(s, "project_summary_export_step"); - return CBM_STORE_ERR; - } - out->summary = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, 0)); - out->source_hash = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, 1)); - out->created_at = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, 2)); - out->updated_at = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, 3)); - sqlite3_finalize(stmt); - if (!out->summary || !out->source_hash || !out->created_at || !out->updated_at) { - cbm_store_project_summary_export_free(out); - store_set_error(s, "project summary export allocation failed"); - return CBM_STORE_ERR; - } - return CBM_STORE_OK; -} - -void cbm_store_project_summary_export_free(cbm_project_summary_export_t *row) { - if (!row) return; - free(row->summary); - free(row->source_hash); - free(row->created_at); - free(row->updated_at); - memset(row, 0, sizeof(*row)); -} - -/* ── Architecture doc discovery ────────────────────────────────── */ - -int cbm_store_find_architecture_docs(cbm_store_t *s, const char *project, char ***out, int *count) { - const char *sql = "SELECT file_path FROM nodes WHERE project=?1 AND label='File' " - "AND (file_path LIKE '%ARCHITECTURE.md' OR file_path LIKE '%ADR.md' " - "OR file_path LIKE '%DECISIONS.md' OR file_path LIKE 'docs/adr/%' " - "OR file_path LIKE 'doc/adr/%' OR file_path LIKE 'adr/%') " - "ORDER BY file_path LIMIT 20"; - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "find_arch_docs"); - return CBM_STORE_ERR; - } - bind_text(stmt, SKIP_ONE, project); - - int cap = ST_INIT_CAP_8; - int n = 0; - char **arr = malloc(cap * sizeof(char *)); - int scan_rc31; - while ((scan_rc31 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - arr = safe_realloc(arr, cap * sizeof(char *)); - } - arr[n++] = cbm_heap_strdup((const char *)sqlite3_column_text(stmt, 0)); - } - if (scan_rc31 != SQLITE_DONE) { /* SCANCHK:31:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - for (int di = 0; di < n; di++) { - free(arr[di]); - } - free(arr); - *out = NULL; - *count = 0; - return CBM_STORE_ERR; - } - sqlite3_finalize(stmt); - *out = arr; - *count = n; - return CBM_STORE_OK; -} - -/* ── Memory management ──────────────────────────────────────────── */ - -void cbm_node_free_fields(cbm_node_t *n) { - safe_str_free(&n->project); - safe_str_free(&n->label); - safe_str_free(&n->name); - safe_str_free(&n->qualified_name); - safe_str_free(&n->file_path); - safe_str_free(&n->properties_json); -} - -void cbm_store_free_nodes(cbm_node_t *nodes, int count) { - if (!nodes) { - return; - } - for (int i = 0; i < count; i++) { - cbm_node_free_fields(&nodes[i]); - } - free(nodes); -} - -void cbm_store_free_edges(cbm_edge_t *edges, int count) { - if (!edges) { - return; - } - for (int i = 0; i < count; i++) { - safe_str_free(&edges[i].project); - safe_str_free(&edges[i].type); - safe_str_free(&edges[i].properties_json); - } - free(edges); -} - -void cbm_project_free_fields(cbm_project_t *p) { - safe_str_free(&p->name); - safe_str_free(&p->indexed_at); - safe_str_free(&p->root_path); -} - -void cbm_store_free_projects(cbm_project_t *projects, int count) { - if (!projects) { - return; - } - for (int i = 0; i < count; i++) { - cbm_project_free_fields(&projects[i]); - } - free(projects); -} - -void cbm_store_free_file_hashes(cbm_file_hash_t *hashes, int count) { - if (!hashes) { - return; - } - for (int i = 0; i < count; i++) { - safe_str_free(&hashes[i].project); - safe_str_free(&hashes[i].rel_path); - safe_str_free(&hashes[i].sha256); - } - free(hashes); -} - -/* ── Vector search ────────────────────────��──────────────────────── */ - -int cbm_store_count_vectors(cbm_store_t *s, const char *project) { - if (!s || !project) { - return 0; - } - sqlite3_stmt *stmt = NULL; - const char *sql = "SELECT count(*) FROM node_vectors WHERE project = ?1"; - if (sqlite3_prepare_v2(s->db, sql, SQLITE_AUTO_LEN, &stmt, NULL) != SQLITE_OK) { - return 0; - } - sqlite3_bind_text(stmt, SKIP_ONE, project, SQLITE_AUTO_LEN, SQLITE_STATIC); - int count = 0; - if (sqlite3_step(stmt) == SQLITE_ROW) { - count = sqlite3_column_int(stmt, 0); - } - sqlite3_finalize(stmt); - return count; -} - -void cbm_store_free_vector_results(cbm_vector_result_t *results, int count) { - if (!results) { - return; - } - for (int i = 0; i < count; i++) { - free(results[i].project); - free(results[i].name); - free(results[i].qualified_name); - free(results[i].file_path); - free(results[i].label); - } - free(results); -} - -/* Per-keyword scoring: score each keyword independently against each node - * vector, then combine using min(cosine_k) across keywords. This ensures - * ALL keywords must be relevant, not just the average. */ -enum { - VS_VEC_DIM = 768, - VS_SPARSE_NNZE = 8, - VS_RI_SEED = 0x52494E44, - VS_MAX_KW = 32, - VS_STR_BUF = 16, -}; - -static cbm_zova_vector_session_t *vs_get_zova_vector_session(cbm_store_t *s, - const char *zova_path); - -static int vs_workspace_vector_state(cbm_store_t *s, const char *project, - char *model, size_t model_size, - int *out_dimensions, int64_t *out_generation) { - if (!s || !s->zova_workspace_query || !project || !model || model_size == 0 || - !out_dimensions || !out_generation) { - return CBM_STORE_ERR; - } - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2( - s->db, - "SELECT s.model_fingerprint,s.vector_dimensions,s.generation " - "FROM cbm_workspace_index_state_v1 s INNER JOIN cbm_projects_v1 p " - "ON p.workspace_key=s.workspace_key JOIN cbm_workspace_registry r " - "ON r.workspace_key=s.workspace_key WHERE r.workspace_id=?1 AND p.project=?2 " - "AND s.generation>0", - -1, &stmt, NULL) != SQLITE_OK) { - return CBM_STORE_ERR; - } - bind_text(stmt, 1, s->zova_graph_workspace_id); - bind_text(stmt, 2, project); - int rc = CBM_STORE_NOT_FOUND; - if (sqlite3_step(stmt) == SQLITE_ROW) { - const char *stored_model = (const char *)sqlite3_column_text(stmt, 0); - int dimensions = sqlite3_column_int(stmt, 1); - int64_t generation = sqlite3_column_int64(stmt, 2); - if (stored_model && stored_model[0] && strlen(stored_model) < model_size && - dimensions > 0 && generation > 0) { - memcpy(model, stored_model, strlen(stored_model) + 1); - *out_dimensions = dimensions; - *out_generation = generation; - rc = CBM_STORE_OK; - } - } - sqlite3_finalize(stmt); - return rc; -} - -static int vs_load_workspace_native_token_vector(cbm_store_t *s, const char *project, - const char *token, float *out, - bool *out_found, - bool *out_generation_mismatch) { - char model[256]; - int dimensions = 0; - int64_t generation = 0; - *out_found = false; - if (out_generation_mismatch) *out_generation_mismatch = false; - int state_rc = vs_workspace_vector_state(s, project, model, sizeof(model), - &dimensions, &generation); - if (state_rc != CBM_STORE_OK || dimensions != VS_VEC_DIM) return CBM_STORE_ERR; - cbm_zova_vector_session_t *session = vs_get_zova_vector_session(s, s->db_path); - int64_t session_generation = 0; - if (!session || cbm_zova_vector_session_generation(session, &session_generation) != 0 || - session_generation != generation) { - if (out_generation_mismatch) *out_generation_mismatch = true; - return CBM_STORE_ERR; - } - int8_t values[VS_VEC_DIM]; - bool found = false; - if (cbm_zova_vector_session_get_workspace_token_i8( - session, s->zova_graph_workspace_id, model, dimensions, token, - values, sizeof(values), &found) != 0) { - return CBM_STORE_ERR; - } - if (found) { - for (int d = 0; d < VS_VEC_DIM; d++) out[d] = (float)values[d] / CBM_STORE_INT8_MAX; - } - *out_found = found; - return CBM_STORE_OK; -} - -/* Try to look up an enriched int8 vector for `token` in the token_vectors - * table. On success, writes the de-quantized float representation to - * `out` and returns true. */ -static bool vs_load_enriched_vector(cbm_store_t *s, const char *project, const char *token, - float *out) { - sqlite3_stmt *tv_stmt = NULL; - const char *tv_sql = "SELECT vector, idf FROM token_vectors" - " WHERE project = ?1 AND token = ?2 LIMIT 1"; - if (sqlite3_prepare_v2(s->db, tv_sql, SQLITE_AUTO_LEN, &tv_stmt, NULL) != SQLITE_OK) { - return false; - } - bool found = false; - sqlite3_bind_text(tv_stmt, SKIP_ONE, project, SQLITE_AUTO_LEN, SQLITE_STATIC); - sqlite3_bind_text(tv_stmt, ST_COL_2, token, SQLITE_AUTO_LEN, SQLITE_STATIC); - if (sqlite3_step(tv_stmt) == SQLITE_ROW) { - const int8_t *vec = (const int8_t *)sqlite3_value_blob(sqlite3_column_value(tv_stmt, 0)); - int vec_len = sqlite3_column_bytes(tv_stmt, 0); - if (vec && vec_len == VS_VEC_DIM) { - for (int d = 0; d < VS_VEC_DIM; d++) { - out[d] = (float)vec[d] / CBM_STORE_INT8_MAX; - } - found = true; - } - } - sqlite3_finalize(tv_stmt); - return found; -} - -/* Sparse-random-index fallback for tokens not present in the enriched table. */ -static void vs_fill_sparse_random(const char *token, float *out) { - uint64_t seed = XXH3_64bits(token, strlen(token)); - for (int i = 0; i < VS_SPARSE_NNZE; i++) { - uint64_t h = XXH3_64bits_withSeed(&i, sizeof(i), seed + VS_RI_SEED); - int pos = (int)(h % VS_VEC_DIM); - float sign = (h & SKIP_ONE) ? CBM_STORE_UNIT_POS_F : -CBM_STORE_UNIT_POS_F; - out[pos] += sign; - } -} - -/* Clamp one float into the int8 representable range. */ -static int8_t vs_clamp_int8(float v) { - if (v > CBM_STORE_INT8_MAX) { - return (int8_t)CBM_STORE_INT8_MAX; - } - if (v < -CBM_STORE_INT8_MAX) { - return (int8_t)-CBM_STORE_INT8_MAX; - } - return (int8_t)v; -} - -/* Normalize + int8 quantize a float vector. Returns false if the vector is - * effectively zero (magnitude below epsilon), in which case `dst` is left in - * an indeterminate state and the caller should skip this keyword. */ -static bool vs_normalize_and_quantize(const float *src, int8_t *dst) { - float mag = 0.0F; - for (int d = 0; d < VS_VEC_DIM; d++) { - mag += src[d] * src[d]; - } - mag = sqrtf(mag); - if (mag < CBM_STORE_DENOM_EPS_F) { - return false; - } - float inv = CBM_STORE_UNIT_POS_F / mag; - for (int d = 0; d < VS_VEC_DIM; d++) { - dst[d] = vs_clamp_int8(src[d] * inv * CBM_STORE_INT8_MAX); - } - return true; -} - -/* Build int8 query vectors for each keyword. Returns the number of - * successfully built vectors (may be less than keyword_count when some - * keywords produce zero-magnitude vectors). */ -static int vs_build_keyword_vectors(cbm_store_t *s, const char *project, const char **keywords, - int keyword_count, int8_t (*kw_vecs)[VS_VEC_DIM], - int *out_actual_kw, - cbm_vector_search_metrics_t *metrics) { - int actual_kw = 0; - for (int k = 0; k < keyword_count && actual_kw < VS_MAX_KW; k++) { - if (!keywords[k] || !keywords[k][0]) { - continue; - } - float kw_f[VS_VEC_DIM]; - memset(kw_f, 0, sizeof(kw_f)); - bool found = false; - if (s->zova_workspace_query) { - bool generation_mismatch = false; - if (vs_load_workspace_native_token_vector( - s, project, keywords[k], kw_f, &found, - &generation_mismatch) != CBM_STORE_OK) { - if (metrics && generation_mismatch) metrics->generation_mismatch = true; - return CBM_STORE_ERR; - } - } else { - found = vs_load_enriched_vector(s, project, keywords[k], kw_f); - } - if (!found) { - vs_fill_sparse_random(keywords[k], kw_f); - } - if (vs_normalize_and_quantize(kw_f, kw_vecs[actual_kw])) { - actual_kw++; - } - } - *out_actual_kw = actual_kw; - return CBM_STORE_OK; -} - -/* Compute the per-keyword min cosine score between a node's int8 vector and - * each of the query vectors. Returns 0.0 if the node vector is unavailable - * or mis-sized. */ -static double vs_min_cosine_score(const int8_t *node_vec, int node_vec_len, - const int8_t (*kw_vecs)[VS_VEC_DIM], int actual_kw) { - if (!node_vec || node_vec_len != VS_VEC_DIM) { - return 0.0; - } - double min_score = CBM_STORE_UNIT_POS_D; - for (int k = 0; k < actual_kw; k++) { - int32_t dot = 0; - int32_t ma = 0; - int32_t mb = 0; - for (int d = 0; d < VS_VEC_DIM; d++) { - dot += (int32_t)kw_vecs[k][d] * (int32_t)node_vec[d]; - ma += (int32_t)kw_vecs[k][d] * (int32_t)kw_vecs[k][d]; - mb += (int32_t)node_vec[d] * (int32_t)node_vec[d]; - } - double denom = sqrt((double)ma) * sqrt((double)mb); - double cos_k = denom > CBM_STORE_DENOM_EPS_D ? (double)dot / denom : 0.0; - if (cos_k < min_score) { - min_score = cos_k; - } - } - return min_score; -} - -/* Append one candidate row read from the scan statement into the result - * vector. Grows the results array geometrically on demand. Returns the - * (possibly grown) results pointer, or NULL on allocation failure. */ -static cbm_vector_result_t *vs_append_result(cbm_vector_result_t *results, int *count, int *cap, - sqlite3_stmt *stmt, - const int8_t (*kw_vecs)[VS_VEC_DIM], int actual_kw) { - if (*count >= *cap) { - int nc = *cap < CBM_SZ_16 ? CBM_SZ_16 : *cap * ST_COL_2; - cbm_vector_result_t *grown = realloc(results, (size_t)nc * sizeof(cbm_vector_result_t)); - if (!grown) { - return NULL; - } - results = grown; - *cap = nc; - } - int idx = (*count)++; - memset(&results[idx], 0, sizeof(results[idx])); - results[idx].node_id = sqlite3_column_int64(stmt, 0); - const char *name = (const char *)sqlite3_column_text(stmt, SKIP_ONE); - const char *qn = (const char *)sqlite3_column_text(stmt, ST_COL_2); - const char *fp = (const char *)sqlite3_column_text(stmt, ST_COL_3); - const char *label = (const char *)sqlite3_column_text(stmt, ST_COL_4); - results[idx].name = name ? strdup(name) : strdup(""); - results[idx].qualified_name = qn ? strdup(qn) : strdup(""); - results[idx].file_path = fp ? strdup(fp) : strdup(""); - results[idx].label = label ? strdup(label) : strdup(""); - const int8_t *node_vec = (const int8_t *)sqlite3_column_blob(stmt, ST_COL_6); - int node_vec_len = sqlite3_column_bytes(stmt, ST_COL_6); - results[idx].score = vs_min_cosine_score(node_vec, node_vec_len, kw_vecs, actual_kw); - return results; -} - -static void vs_sort_and_trim(cbm_vector_result_t *results, int *count, int limit) { - if (!results || !count) { - return; - } - for (int i = 0; i < *count - SKIP_ONE; i++) { - for (int j = i + SKIP_ONE; j < *count; j++) { - bool higher_score = results[j].score > results[i].score; - bool stable_tie = results[j].score == results[i].score && - strcmp(results[j].qualified_name ? results[j].qualified_name : "", - results[i].qualified_name ? results[i].qualified_name : "") < 0; - if (higher_score || stable_tie) { - cbm_vector_result_t tmp = results[i]; - results[i] = results[j]; - results[j] = tmp; - } - } - } - - int final_limit = limit > 0 ? limit : CBM_SZ_16; - if (*count > final_limit) { - for (int i = final_limit; i < *count; i++) { - free(results[i].name); - free(results[i].qualified_name); - free(results[i].file_path); - free(results[i].label); - } - *count = final_limit; - } -} - -static double vs_elapsed_ms(const struct timespec *start, const struct timespec *end) { - return ((double)(end->tv_sec - start->tv_sec) * 1000.0) + - ((double)(end->tv_nsec - start->tv_nsec) / 1000000.0); -} - -static void vs_record_elapsed(const struct timespec *start, double *out_ms) { - struct timespec end; - cbm_clock_gettime(CLOCK_MONOTONIC, &end); - *out_ms = vs_elapsed_ms(start, &end); -} - -static void store_close_zova_vector_session(cbm_store_t *s) { - if (!s) { - return; - } - cbm_zova_vector_session_close(s->zova_vector_session); - s->zova_vector_session = NULL; - free(s->zova_vector_session_path); - s->zova_vector_session_path = NULL; - s->zova_vector_session_stat_valid = false; -} - -static bool vs_zova_session_matches(const cbm_store_t *s, const char *zova_path, - const struct stat *st) { - return s && s->zova_vector_session && s->zova_vector_session_path && - s->zova_vector_session_stat_valid && strcmp(s->zova_vector_session_path, zova_path) == 0 && - s->zova_vector_session_stat.st_dev == st->st_dev && - s->zova_vector_session_stat.st_ino == st->st_ino && - s->zova_vector_session_stat.st_size == st->st_size && - s->zova_vector_session_stat.st_mtime == st->st_mtime; -} - -static cbm_zova_vector_session_t *vs_get_zova_vector_session(cbm_store_t *s, - const char *zova_path) { - struct stat st; - if (!s || !zova_path || stat(zova_path, &st) != 0) { - return NULL; - } - if (vs_zova_session_matches(s, zova_path, &st)) { - return s->zova_vector_session; - } - store_close_zova_vector_session(s); - cbm_zova_vector_session_t *session = cbm_zova_vector_session_open(zova_path); - char *path_copy = session ? cbm_heap_strdup(zova_path) : NULL; - if (!session || !path_copy) { - cbm_zova_vector_session_close(session); - return NULL; - } - s->zova_vector_session = session; - s->zova_vector_session_path = path_copy; - s->zova_vector_session_stat = st; - s->zova_vector_session_stat_valid = true; - return session; -} - -static void store_close_zova_graph_session(cbm_store_t *s) { - if (!s) { - return; - } - cbm_zova_graph_session_close(s->zova_graph_session); - s->zova_graph_session = NULL; -} - -static void store_clear_zova_graph_workspace(cbm_store_t *s) { - if (!s) { - return; - } - free(s->zova_graph_workspace_root); - s->zova_graph_workspace_root = NULL; - s->zova_graph_workspace_id[0] = '\0'; -} - -static int vs_try_zova_workspace_native_vector_search( - cbm_store_t *s, const char *project, const int8_t (*kw_vecs)[VS_VEC_DIM], int actual_kw, - int limit, cbm_vector_result_t **out, int *out_count, - cbm_vector_search_metrics_t *metrics) { - if (!s || !s->zova_workspace_query) return CBM_STORE_NOT_FOUND; - - char model[256]; - int vector_dimensions = 0; - int64_t generation = 0; - if (vs_workspace_vector_state(s, project, model, sizeof(model), - &vector_dimensions, &generation) != CBM_STORE_OK || - vector_dimensions != VS_VEC_DIM) { - return CBM_STORE_ERR; - } - cbm_zova_vector_session_t *session = vs_get_zova_vector_session(s, s->db_path); - int64_t session_generation = 0; - if (!session || cbm_zova_vector_session_generation(session, &session_generation) != 0 || - session_generation != generation) { - if (metrics) metrics->generation_mismatch = true; - return CBM_STORE_ERR; - } - char collection[CBM_ZOVA_WORKSPACE_ID_MAX + 384]; - if (cbm_zova_workspace_node_vector_collection_name( - s->zova_graph_workspace_id, model, vector_dimensions, - collection, sizeof(collection)) != 0) { - return CBM_STORE_ERR; - } - - int requested_limit = limit > 0 ? limit : CBM_SZ_16; - int fetch_limit = requested_limit * ST_COL_5; - struct timespec started; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - cbm_zova_vector_hit_t *hits = NULL; - int hit_count = 0; - if (cbm_zova_vector_session_search_collection_i8( - session, collection, &kw_vecs[0][0], actual_kw, vector_dimensions, - fetch_limit, fetch_limit, &hits, &hit_count) != 0) { - return CBM_STORE_ERR; - } - if (metrics) { - metrics->used_zova = true; - metrics->zova_native_multi_query = actual_kw > 1; - metrics->zova_fetch_limit = fetch_limit; - vs_record_elapsed(&started, &metrics->zova_prefetch_ms); - } - if (hit_count == 0) { - cbm_zova_vector_hits_free(hits, hit_count); - return CBM_STORE_OK; - } - - if (!s->zova_repository) { - cbm_zova_vector_hits_free(hits, hit_count); - return CBM_STORE_ERR; - } - cbm_vector_result_t *results = calloc((size_t)hit_count, sizeof(*results)); - int64_t *node_keys = calloc((size_t)hit_count, sizeof(*node_keys)); - cbm_node_t *nodes = NULL; - if (!results || !node_keys) { - free(results); - free(node_keys); - cbm_zova_vector_hits_free(hits, hit_count); - return CBM_STORE_ERR; - } - for (int i = 0; i < hit_count; i++) { - char *end = NULL; - long long key = hits[i].vector_id ? strtoll(hits[i].vector_id, &end, 10) : 0; - if (!hits[i].vector_id || end == hits[i].vector_id || *end != '\0' || key <= 0) { - free(node_keys); - free(results); - cbm_zova_vector_hits_free(hits, hit_count); - return CBM_STORE_ERR; - } - node_keys[i] = (int64_t)key; - } - if (cbm_zova_repository_find_nodes_by_keys( - s->zova_repository, s->zova_graph_workspace_id, node_keys, - hit_count, &nodes) != CBM_STORE_OK) { - free(node_keys); - free(results); - cbm_zova_vector_hits_free(hits, hit_count); - return CBM_STORE_ERR; - } - free(node_keys); - int count = 0; - int rc = CBM_STORE_OK; - for (int i = 0; i < hit_count; i++) { - cbm_node_t *node = &nodes[i]; - if (!node->label || (strcmp(node->label, "Function") != 0 && - strcmp(node->label, "Method") != 0 && - strcmp(node->label, "Class") != 0)) { - cbm_node_free_fields(node); - continue; - } - cbm_vector_result_t *item = &results[count++]; - item->node_id = node->id; - item->name = (char *)node->name; node->name = NULL; - item->qualified_name = (char *)node->qualified_name; node->qualified_name = NULL; - item->file_path = (char *)node->file_path; node->file_path = NULL; - item->label = (char *)node->label; node->label = NULL; - item->score = hits[i].score; - cbm_node_free_fields(node); - if (!item->name || !item->qualified_name || !item->file_path || !item->label) { - rc = CBM_STORE_ERR; - break; - } - } - cbm_store_free_nodes(nodes, hit_count); - cbm_zova_vector_hits_free(hits, hit_count); - if (rc != CBM_STORE_OK) { - cbm_store_free_vector_results(results, count); - return CBM_STORE_ERR; - } - vs_sort_and_trim(results, &count, limit); - *out = results; - *out_count = count; - return CBM_STORE_OK; -} - -static int vs_try_zova_vector_search(cbm_store_t *s, const char *project, - const int8_t (*kw_vecs)[VS_VEC_DIM], int actual_kw, - int limit, cbm_vector_result_t **out, int *out_count, - cbm_vector_search_metrics_t *metrics) { - if (!cbm_zova_build_enabled() || !s || !s->db_path || !s->zova_workspace_query) { - return CBM_STORE_NOT_FOUND; - } - return vs_try_zova_workspace_native_vector_search(s, project, kw_vecs, actual_kw, limit, out, - out_count, metrics); -} - -int cbm_store_vector_search_ex(cbm_store_t *s, const char *project, const char **keywords, - int keyword_count, int limit, cbm_vector_result_t **out, - int *out_count, cbm_vector_search_metrics_t *metrics) { - if (metrics) { - memset(metrics, 0, sizeof(*metrics)); - } - *out = NULL; - *out_count = 0; - if (!s || !project || !keywords || keyword_count <= 0) { - return CBM_STORE_ERR; - } - - struct timespec stage_start; - cbm_clock_gettime(CLOCK_MONOTONIC, &stage_start); - int8_t kw_vecs[VS_MAX_KW][VS_VEC_DIM]; - int actual_kw = 0; - if (vs_build_keyword_vectors(s, project, keywords, keyword_count, kw_vecs, - &actual_kw, metrics) != CBM_STORE_OK) { - return CBM_STORE_ERR; - } - if (metrics) { - vs_record_elapsed(&stage_start, &metrics->query_vector_build_ms); - } - if (actual_kw == 0) { - return CBM_STORE_OK; - } - - int zova_rc = - vs_try_zova_vector_search(s, project, kw_vecs, actual_kw, limit, out, out_count, metrics); - if (zova_rc == CBM_STORE_OK) { - return CBM_STORE_OK; - } - if (zova_rc == CBM_STORE_ERR) { - return CBM_STORE_ERR; - } - if (s->zova_workspace_query) { - return CBM_STORE_ERR; - } - - /* Scan all node vectors, compute per-keyword cosine, take min. - * We use the FIRST keyword as the SQL sort (for top-K pre-filter), - * then re-score with min across all keywords in the append helper. */ - const char *sql = NULL; - if (s->zova_workspace_query) { - sql = "SELECT n.id,n.name,n.qualified_name,n.file_path,n.label," - "cbm_cosine_i8(v.vector,?1) AS score,v.vector FROM node_vectors v " - "INNER JOIN nodes n ON n.id=v.node_id " - "WHERE n.label IN ('Function','Method','Class') ORDER BY score DESC LIMIT ?2"; - } else { - sql = "SELECT n.id,n.name,n.qualified_name,n.file_path,n.label," - "cbm_cosine_i8(v.vector,?1) AS score,v.vector FROM node_vectors v " - "INNER JOIN nodes n ON n.id=v.node_id WHERE v.project=?2 " - "AND n.label IN ('Function','Method','Class') ORDER BY score DESC LIMIT ?3"; - } - - sqlite3_stmt *stmt = NULL; - int prep_rc = sqlite3_prepare_v2(s->db, sql, SQLITE_AUTO_LEN, &stmt, NULL); - if (prep_rc != SQLITE_OK) { - (void)fprintf(stderr, "vector_search: %s\n", sqlite3_errmsg(s->db)); - return CBM_STORE_ERR; - } - - /* Use first keyword for SQL pre-filter, fetch more candidates for re-ranking */ - int fetch_limit = (limit > 0 ? limit : CBM_SZ_16) * ST_COL_5; - sqlite3_bind_blob(stmt, SKIP_ONE, kw_vecs[0], VS_VEC_DIM, SQLITE_STATIC); - if (s->zova_workspace_query) { - sqlite3_bind_int(stmt, ST_COL_2, fetch_limit); - } else { - sqlite3_bind_text(stmt, ST_COL_2, project, SQLITE_AUTO_LEN, SQLITE_STATIC); - sqlite3_bind_int(stmt, ST_COL_3, fetch_limit); - } - - { - char kw_buf[VS_STR_BUF]; - char fl_buf[VS_STR_BUF]; - snprintf(kw_buf, sizeof(kw_buf), "%d", actual_kw); - snprintf(fl_buf, sizeof(fl_buf), "%d", fetch_limit); - cbm_log_info("vector_search.exec", "kw_count", kw_buf, "fetch_limit", fl_buf, "project", - project); - } - - cbm_vector_result_t *results = NULL; - int count = 0; - int cap = 0; - int step_rc = 0; - while ((step_rc = sqlite3_step(stmt)) == SQLITE_ROW) { - cbm_vector_result_t *grown = - vs_append_result(results, &count, &cap, stmt, kw_vecs, actual_kw); - if (!grown) { - break; - } - results = grown; - } - - if (step_rc != SQLITE_DONE) { - char rc_buf[VS_STR_BUF]; - snprintf(rc_buf, sizeof(rc_buf), "%d", step_rc); - cbm_log_warn("vector_search.step_error", "rc", rc_buf, "msg", sqlite3_errmsg(s->db)); - } - { - char cnt_buf[VS_STR_BUF]; - snprintf(cnt_buf, sizeof(cnt_buf), "%d", count); - cbm_log_info("vector_search.done", "candidates", cnt_buf); - } - sqlite3_finalize(stmt); - - vs_sort_and_trim(results, &count, limit); - - *out = results; - *out_count = count; - return CBM_STORE_OK; -} - -int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **keywords, - int keyword_count, int limit, cbm_vector_result_t **out, - int *out_count) { - return cbm_store_vector_search_ex(s, project, keywords, keyword_count, limit, out, out_count, - NULL); -} - -static int multi_vector_result_compare(const void *left, const void *right) { - const cbm_vector_result_t *a = left; - const cbm_vector_result_t *b = right; - if (a->score > b->score) return -1; - if (a->score < b->score) return 1; - int by_project = strcmp(a->project, b->project); - if (by_project != 0) return by_project; - int by_qn = strcmp(a->qualified_name, b->qualified_name); - if (by_qn != 0) return by_qn; - return (a->node_id > b->node_id) - (a->node_id < b->node_id); -} - -int cbm_store_zova_multi_vector_search( - const char *db_path, const cbm_zova_vector_workspace_t *workspaces, int workspace_count, - const char **keywords, int keyword_count, int limit, int offset, - cbm_vector_result_t **out, int *out_count) { - if (!out || !out_count) return CBM_STORE_ERR; - *out = NULL; - *out_count = 0; - if (!db_path || !workspaces || workspace_count <= 0 || !keywords || keyword_count <= 0 || - limit <= 0 || offset < 0 || offset > INT_MAX - limit) { - return CBM_STORE_ERR; - } - int needed = offset + limit; - cbm_store_t *store = cbm_store_open_path_query(db_path); - cbm_zova_vector_session_t *session = cbm_zova_vector_session_open(db_path); - if (!store || !session) { - cbm_store_close(store); - cbm_zova_vector_session_close(session); - return CBM_STORE_ERR; - } - sqlite3_stmt *hydrate = NULL; - if (sqlite3_prepare_v2( - store->db, - "SELECT n.label,n.name,n.qualified_name,f.file_path FROM cbm_nodes_v1 n " - "JOIN cbm_files_v1 f ON f.file_key=n.file_key WHERE n.workspace_key=?1 " - "AND n.zova_node_key=?2", - -1, &hydrate, NULL) != SQLITE_OK) { - cbm_store_close(store); - cbm_zova_vector_session_close(session); - return CBM_STORE_ERR; - } - - cbm_vector_result_t *combined = NULL; - int combined_count = 0; - int combined_capacity = 0; - int rc = CBM_STORE_OK; - for (int w = 0; w < workspace_count && rc == CBM_STORE_OK; w++) { - const cbm_zova_vector_workspace_t *workspace = &workspaces[w]; - if (!workspace->workspace_id || !workspace->selector || !workspace->model_fingerprint || - workspace->vector_dimensions != VS_VEC_DIM) { - rc = CBM_STORE_ERR; - break; - } - int8_t query_vectors[VS_MAX_KW][VS_VEC_DIM]; - int query_count = 0; - for (int k = 0; k < keyword_count && query_count < VS_MAX_KW; k++) { - if (!keywords[k] || !keywords[k][0]) continue; - int8_t token_vector[VS_VEC_DIM]; - bool found = false; - float values[VS_VEC_DIM] = {0}; - if (cbm_zova_vector_session_get_workspace_token_i8( - session, workspace->workspace_id, workspace->model_fingerprint, - workspace->vector_dimensions, keywords[k], token_vector, - sizeof(token_vector), &found) != 0) { - rc = CBM_STORE_ERR; - break; - } - if (found) { - for (int d = 0; d < VS_VEC_DIM; d++) - values[d] = (float)token_vector[d] / CBM_STORE_INT8_MAX; - } else { - vs_fill_sparse_random(keywords[k], values); - } - if (vs_normalize_and_quantize(values, query_vectors[query_count])) query_count++; - } - if (rc != CBM_STORE_OK || query_count == 0) continue; - - char collection[CBM_ZOVA_WORKSPACE_ID_MAX + 384]; - if (cbm_zova_workspace_node_vector_collection_name( - workspace->workspace_id, workspace->model_fingerprint, - workspace->vector_dimensions, collection, sizeof(collection)) != 0) { - rc = CBM_STORE_ERR; - break; - } - int prefilter = query_count > 1 && needed <= INT_MAX / ST_COL_5 - ? needed * ST_COL_5 - : needed; - cbm_zova_vector_hit_t *hits = NULL; - int hit_count = 0; - if (cbm_zova_vector_session_search_collection_i8( - session, collection, &query_vectors[0][0], query_count, VS_VEC_DIM, - prefilter, needed, &hits, &hit_count) != 0) { - rc = CBM_STORE_ERR; - break; - } - for (int i = 0; i < hit_count && rc == CBM_STORE_OK; i++) { - char *end = NULL; - long long key = hits[i].vector_id ? strtoll(hits[i].vector_id, &end, 10) : 0; - if (!hits[i].vector_id || end == hits[i].vector_id || *end != '\0' || key <= 0 || - sqlite3_reset(hydrate) != SQLITE_OK || - sqlite3_clear_bindings(hydrate) != SQLITE_OK || - sqlite3_bind_int64(hydrate, 1, workspace->workspace_key) != SQLITE_OK || - sqlite3_bind_int64(hydrate, 2, (int64_t)key) != SQLITE_OK) { - rc = CBM_STORE_ERR; - break; - } - if (sqlite3_step(hydrate) != SQLITE_ROW) { - rc = CBM_STORE_ERR; - break; - } - const char *label = (const char *)sqlite3_column_text(hydrate, 0); - if (!label || (strcmp(label, "Function") != 0 && strcmp(label, "Method") != 0 && - strcmp(label, "Class") != 0)) { - continue; - } - if (combined_count == combined_capacity) { - if (combined_capacity > INT_MAX / 2) { - rc = CBM_STORE_ERR; - break; - } - int next = combined_capacity ? combined_capacity * 2 : CBM_SZ_16; - cbm_vector_result_t *grown = - realloc(combined, (size_t)next * sizeof(*combined)); - if (!grown) { - rc = CBM_STORE_ERR; - break; - } - combined = grown; - combined_capacity = next; - } - cbm_vector_result_t *result = &combined[combined_count]; - memset(result, 0, sizeof(*result)); - result->node_id = (int64_t)key; - result->project = strdup(workspace->selector); - result->label = strdup(label); - const char *name = (const char *)sqlite3_column_text(hydrate, 1); - const char *qn = (const char *)sqlite3_column_text(hydrate, 2); - const char *file = (const char *)sqlite3_column_text(hydrate, 3); - result->name = strdup(name ? name : ""); - result->qualified_name = strdup(qn ? qn : ""); - result->file_path = strdup(file ? file : ""); - result->score = hits[i].score; - if (!result->project || !result->label || !result->name || - !result->qualified_name || !result->file_path) { - free(result->project); - free(result->label); - free(result->name); - free(result->qualified_name); - free(result->file_path); - memset(result, 0, sizeof(*result)); - rc = CBM_STORE_ERR; - break; - } - combined_count++; - } - cbm_zova_vector_hits_free(hits, hit_count); - } - sqlite3_finalize(hydrate); - cbm_store_close(store); - cbm_zova_vector_session_close(session); - if (rc != CBM_STORE_OK) { - cbm_store_free_vector_results(combined, combined_count); - return rc; - } - if (combined_count > 1) { - qsort(combined, (size_t)combined_count, sizeof(*combined), multi_vector_result_compare); - } - int start = offset < combined_count ? offset : combined_count; - int end = start + limit < combined_count ? start + limit : combined_count; - int page_count = end - start; - cbm_vector_result_t *page = page_count ? calloc((size_t)page_count, sizeof(*page)) : NULL; - if (page_count && !page) { - cbm_store_free_vector_results(combined, combined_count); - return CBM_STORE_ERR; - } - for (int i = 0; i < page_count; i++) { - page[i] = combined[start + i]; - memset(&combined[start + i], 0, sizeof(*combined)); - } - cbm_store_free_vector_results(combined, combined_count); - *out = page; - *out_count = page_count; - return CBM_STORE_OK; -} diff --git a/src/store/store.zig b/src/store/store.zig new file mode 100644 index 00000000..348d70e1 --- /dev/null +++ b/src/store/store.zig @@ -0,0 +1,6913 @@ +//! store.zig — SQLite graph store implementation. +//! +//! Implements src/store/store.h: the opaque cbm_store_t handle with prepared +//! statement caching, schema initialization, and all CRUD operations for +//! nodes, edges, projects, file hashes, search, BFS traversal, and schema +//! introspection. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); +const interop = @import("../foundation/interop.zig"); + +const Str = interop.Str; +const CStr = interop.CStr; +const Stmt = c.sqlite3_stmt; + +const OK = c.CBM_STORE_OK; +const ERR = c.CBM_STORE_ERR; +const NOT_FOUND = c.CBM_STORE_NOT_FOUND; + +const ST_BUF_16 = 16; +const ST_BUF_64 = 64; +/// file: URI for the immutable read-only fallback. A path is at most +/// CBM_SZ_1K; percent-encoding can triple it, plus the "file://" prefix, +/// the "?immutable=1" suffix and a leading '/'. +const ST_QUERY_URI_MAX = 3 * 1024 + 64; +/// Matches the PRAGMA busy_timeout configurePragmas applies. +const ST_BUSY_TIMEOUT_MS = 10000; +const ST_GROWTH = 2; +const ST_MAX_DEGREE = 8192; +const ST_INIT_CAP_8 = 8; +const ST_INIT_CAP_16 = 16; +const ST_SQL_BUF = 8192; +const ST_QN_MAX_DOTS = 5; +const ST_QN_MIN_DOTS = 3; +const ST_IN_CLAUSE_MARGIN = 4; +const ST_GLOB_MIN_LEN = 3; +const ST_MAX_LANG = 10; +const ST_SEARCH_MAX_BINDS = 32; // increased: LIKE pre-filter adds binds per pattern +const ST_LIKE_POOL_MAX = 12; // max malloc'd LIKE strings alive during one search +const ST_LIKE_HINT_MAX = 2; // max LIKE hints extracted per regex pattern +const ST_MAX_PKGS = 64; +const ST_INIT_CAP_4 = 4; +const ST_MIN_INDEGREE = 3; +const ST_MAX_PATH_DEPTH = 3; +const ST_MAX_ITERATIONS = 10; + +// ── SQLite bind helpers ───────────────────────────────────────── + +/// SQLITE_TRANSIENT, ((void *)-1). Type-punned through memory because -1 is +/// not an aligned function address, so no pointer cast can produce it. +fn transient() c.sqlite3_destructor_type { + var raw: usize = std.math.maxInt(usize); + _ = &raw; + return @as(*const c.sqlite3_destructor_type, @ptrCast(&raw)).*; +} + +fn bindText(stmt: ?*Stmt, col: c_int, v: CStr) c_int { + return c.sqlite3_bind_text(stmt, col, v, -1, transient()); +} + +fn columnText(stmt: ?*Stmt, col: c_int) CStr { + return c.sqlite3_column_text(stmt, col); +} + +/// A malloc'd copy of a text column; NULL for SQL NULL. +fn columnDup(stmt: ?*Stmt, col: c_int) [*c]u8 { + return interop.heapStrdup(columnText(stmt, col)); +} + +fn prepare(db: ?*c.sqlite3, sql: CStr, stmt: *?*Stmt) c_int { + return c.sqlite3_prepare_v2(db, sql, -1, stmt, null); +} + +// ── Internal store structure ───────────────────────────────────── + +/// One cbm edge of a Zova workspace store: Zova node keys, which are also the +/// ids of the nodes view, and an index into the store's edge type names. +const ZovaEdge = extern struct { + source_key: i64, + target_key: i64, + type: c_int, +}; + +/// Prepared statements (lazily initialized, cached for lifetime). +const Stmts = extern struct { + upsert_node: ?*Stmt, + find_node_by_id: ?*Stmt, + find_node_by_qn: ?*Stmt, + find_node_by_qn_any: ?*Stmt, // QN lookup without project filter + find_nodes_by_name: ?*Stmt, + find_nodes_by_name_any: ?*Stmt, // name lookup without project filter + find_nodes: ?*Stmt, + find_nodes_by_label: ?*Stmt, + find_nodes_by_file: ?*Stmt, + count_nodes: ?*Stmt, + delete_nodes_by_project: ?*Stmt, + delete_nodes_by_file: ?*Stmt, + delete_nodes_by_label: ?*Stmt, + + insert_edge: ?*Stmt, + find_edges_by_source: ?*Stmt, + find_edges_by_target: ?*Stmt, + find_edges_by_source_type: ?*Stmt, + find_edges_by_target_type: ?*Stmt, + find_edges_by_type: ?*Stmt, + count_edges: ?*Stmt, + count_edges_by_type: ?*Stmt, + delete_edges_by_project: ?*Stmt, + delete_edges_by_type: ?*Stmt, + + upsert_project: ?*Stmt, + get_project: ?*Stmt, + list_projects: ?*Stmt, + delete_project: ?*Stmt, + + upsert_file_hash: ?*Stmt, + get_file_hashes: ?*Stmt, + delete_file_hash: ?*Stmt, + delete_file_hashes: ?*Stmt, +}; + +const Store = extern struct { + db: ?*c.sqlite3, + db_path: CStr, // heap-allocated, or NULL for :memory: + /// Set only by cbm_store_open_backend; db is NULL then. + backend: ?*const c.cbm_store_backend_t, + backend_ctx: ?*anyopaque, + errbuf: [c.CBM_SZ_512]u8, + + /// Read-only Zova sidecar session. One store is single-thread-owned. + zova_vector_session: ?*c.cbm_zova_vector_session_t, + zova_vector_session_path: [*c]u8, + zova_vector_session_stat: c.struct_stat, + zova_vector_session_stat_valid: bool, + + /// Read-only Zova graph session. Separate from vectors so either sidecar + /// capability can be unavailable without disturbing the other. + zova_graph_session: ?*c.cbm_zova_graph_session_t, + zova_graph_workspace_root: [*c]u8, + zova_graph_workspace_id: [c.CBM_ZOVA_WORKSPACE_ID_MAX]u8, + zova_graph_cached_numeric_id: i64, + zova_graph_cached_stable_id: [c.CBM_ZOVA_WORKSPACE_ID_MAX + 96]u8, + zova_graph_cached_stable_id_valid: bool, + zova_workspace_query: bool, + zova_repository: ?*c.cbm_zova_repository_t, + /// Legacy nodes view over the workspace; see storeLegacyScope. + zova_legacy_project: [*c]u8, + /// The workspace's edges, read once from the graph; see storeZovaEdges. + zova_edges: [*c]ZovaEdge, + zova_edge_count: c_int, + zova_edges_loaded: bool, + zova_edge_types: [*c][*c]u8, + zova_edge_type_count: c_int, + zova_native_topology_ops: c_int, + + stmts: Stmts, + + fn zovaEdges(s: *const Store) []ZovaEdge { + return span(s.zova_edges, s.zova_edge_count); + } + + fn zovaEdgeTypes(s: *const Store) [][*c]u8 { + return span(s.zova_edge_types, s.zova_edge_type_count); + } +}; + +// ── Helpers ────────────────────────────────────────────────────── + +fn storeSetError(s: *Store, msg: []const u8) void { + _ = interop.bprint(&s.errbuf, "{s}", .{msg}); +} + +fn storeSetErrorSqlite(s: *Store, prefix: []const u8) void { + _ = interop.bprint(&s.errbuf, "{s}: {s}", .{ prefix, interop.sp(c.sqlite3_errmsg(s.db)) }); +} + +fn execSql(s_c: ?*Store, sql: CStr) c_int { + const s = s_c orelse return ERR; + if (s.db == null) return ERR; + var err: [*c]u8 = null; + const rc = c.sqlite3_exec(s.db, sql, null, null, &err); + if (rc != c.SQLITE_OK) { + _ = interop.bprint(&s.errbuf, "exec: {s}", .{if (err != null) std.mem.span(err) else "unknown"}); + c.sqlite3_free(err); + return ERR; + } + return OK; +} + +/// Safe string: returns "" if NULL. +fn safeStr(s: CStr) CStr { + return if (s != null) s else ""; +} + +/// Safe properties: returns "{}" if NULL. +fn safeProps(s: CStr) CStr { + return if (s != null and s[0] != 0) s else "{}"; +} + +fn streq(a: CStr, b: CStr) bool { + return c.strcmp(a, b) == 0; +} + +/// Prepare a statement (cached). If already prepared, reset+clear. +fn prepareCached(s: *Store, slot: *?*Stmt, sql: CStr) ?*Stmt { + if (s.db == null) return null; + if (slot.*) |stmt| { + _ = c.sqlite3_reset(stmt); + _ = c.sqlite3_clear_bindings(stmt); + return stmt; + } + if (prepare(s.db, sql, slot) != c.SQLITE_OK) { + storeSetErrorSqlite(s, "prepare"); + return null; + } + return slot.*; +} + +/// Get ISO-8601 timestamp. +fn isoNow(buf: []u8) void { + var t: c.time_t = c.time(null); + var tm: c.struct_tm = undefined; + _ = c.cbm_gmtime_r(&t, &tm); + // strftime only fails if the buffer is too small — the 21-byte ISO + // timestamp always fits in caller-provided buffers + _ = c.strftime(buf.ptr, buf.len, "%Y-%m-%dT%H:%M:%SZ", &tm); +} + +/// A zeroed malloc'd array of n items; NULL on OOM. +fn callocSlice(comptime T: type, n: usize) ?[]T { + const items: ?[*]T = interop.callocN(T, n); + return (items orelse return null)[0..n]; +} + +/// A zeroed malloc'd T; NULL on OOM. +fn callocOne(comptime T: type) ?*T { + return @ptrCast(@alignCast(c.calloc(1, @sizeOf(T)))); +} + +/// CBM_SNPRINTF_APPEND for a fixed buffer: appends formatted text at `len` +/// and returns the new length, clamped so the buffer always stays +/// NUL-terminated. +fn appendf(buf: []u8, len: usize, comptime fmt: []const u8, args: anytype) usize { + if (len >= buf.len) return len; + const n = interop.bprint(buf[len..], fmt, args); + return @min(len + n, buf.len - 1); +} + +// ── Schema ─────────────────────────────────────────────────────── + +fn initSchema(s: *Store) c_int { + const ddl = + "CREATE TABLE IF NOT EXISTS projects (" ++ + " name TEXT PRIMARY KEY," ++ + " indexed_at TEXT NOT NULL," ++ + " root_path TEXT NOT NULL" ++ + ");" ++ + "CREATE TABLE IF NOT EXISTS file_hashes (" ++ + " project TEXT NOT NULL REFERENCES projects(name) ON DELETE CASCADE," ++ + " rel_path TEXT NOT NULL," ++ + " sha256 TEXT NOT NULL," ++ + " mtime_ns INTEGER NOT NULL DEFAULT 0," ++ + " size INTEGER NOT NULL DEFAULT 0," ++ + " PRIMARY KEY (project, rel_path)" ++ + ");" ++ + "CREATE TABLE IF NOT EXISTS nodes (" ++ + " id INTEGER PRIMARY KEY AUTOINCREMENT," ++ + " project TEXT NOT NULL REFERENCES projects(name) ON DELETE CASCADE," ++ + " label TEXT NOT NULL," ++ + " name TEXT NOT NULL," ++ + " qualified_name TEXT NOT NULL," ++ + " file_path TEXT DEFAULT ''," ++ + " start_line INTEGER DEFAULT 0," ++ + " end_line INTEGER DEFAULT 0," ++ + " properties TEXT DEFAULT '{}'," ++ + " UNIQUE(project, qualified_name)" ++ + ");" ++ + // local_name_gen (#768): IMPORTS edges carry one imported symbol's + // local_name each, so uniqueness must discriminate on it — two named + // imports from the same specifier are distinct edges. Non-IMPORTS + // edges get '' (NOT NULL: NULLs never conflict in a UNIQUE index, + // which would break their dedup entirely). Mirrors the graph-buffer + // dedup key (makeEdgeKey) — keep both in sync. + "CREATE TABLE IF NOT EXISTS edges (" ++ + " id INTEGER PRIMARY KEY AUTOINCREMENT," ++ + " project TEXT NOT NULL REFERENCES projects(name) ON DELETE CASCADE," ++ + " source_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE," ++ + " target_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE," ++ + " type TEXT NOT NULL," ++ + " properties TEXT DEFAULT '{}'," ++ + " url_path_gen TEXT GENERATED ALWAYS AS (json_extract(properties,'$.url_path'))," ++ + " local_name_gen TEXT GENERATED ALWAYS AS (CASE WHEN type='IMPORTS'" ++ + " THEN coalesce(json_extract(properties,'$.local_name'),'') ELSE '' END)," ++ + " UNIQUE(source_id, target_id, type, local_name_gen)" ++ + ");" ++ + "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" ++ + ");"; + + const rc = execSql(s, ddl); + if (rc != OK) return rc; + + // Schema-compat probe (#768): DBs created before the local_name_gen + // discriminator still enforce UNIQUE(source_id,target_id,type) and lack + // the column — the widened upsert in cbm_store_insert_edge can neither + // prepare nor let two named imports coexist against them. SQLite cannot + // ALTER a table-level UNIQUE constraint in place, so fail the open: + // callers already treat an unopenable DB as incompatible (a full index + // deletes + rebuilds it, artifact import refuses and falls back to a + // reindex). Read-only query opens skip initSchema and keep working. + { + var probe: ?*Stmt = null; + if (prepare(s.db, "SELECT local_name_gen FROM edges LIMIT 0;", &probe) != c.SQLITE_OK) { + interop.logWarn("store.schema", .{ "result", "incompatible", "missing", "edges.local_name_gen" }); + return ERR; + } + _ = c.sqlite3_finalize(probe); + } + + // FTS5 contentless virtual table for BM25 full-text search. + // Contentless (content='') means FTS5 stores only the inverted index, + // not a copy of the source text — required for camelCase tokenization + // because we feed it `cbm_camel_split(name)` at insert time but want + // queries to match against the split tokens, not the original. + // Fails silently if FTS5 is not compiled in (SQLITE_ENABLE_FTS5). + { + var fts_err: [*c]u8 = null; + const fts_rc = c.sqlite3_exec(s.db, "CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(" ++ + " name, qualified_name, label, file_path," ++ + " content=''," ++ + " tokenize='unicode61 remove_diacritics 2'" ++ + ");", null, null, &fts_err); + if (fts_rc != c.SQLITE_OK and fts_err != null) c.sqlite3_free(fts_err); + } + return OK; +} + +fn createUserIndexes(s: ?*Store) c_int { + const sql = + "CREATE INDEX IF NOT EXISTS idx_nodes_label ON nodes(project, label);" ++ + "CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(project, name);" ++ + "CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(project, file_path);" ++ + "CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id, type);" ++ + "CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id, type);" ++ + "CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(project, type);" ++ + "CREATE INDEX IF NOT EXISTS idx_edges_target_type ON edges(project, target_id, type);" ++ + "CREATE INDEX IF NOT EXISTS idx_edges_source_type ON edges(project, source_id, type);" ++ + "CREATE INDEX IF NOT EXISTS idx_edges_url_path ON edges(project, url_path_gen);"; + // NOTE: a partial expression index on json_extract(properties,'$.is_entry_point') + // was tried for archEntryPoints and REVERTED: json_extract in an index WHERE + // aborts CREATE INDEX (and thus store open) on any row whose properties JSON is + // malformed — and pre-fix databases contain such rows (see + // pipeline_def_props_valid_json_when_oversized). Revisit only with a + // json_valid()-guarded expression once legacy DBs have aged out. + return execSql(s, sql); +} + +pub export fn cbm_store_resolve_mmap_size() i64 { + const MMAP_DEFAULT = 67108864; // default 64 MB + var buf: [ST_BUF_64]u8 = undefined; + if (c.cbm_safe_getenv("CBM_SQLITE_MMAP_SIZE", &buf, buf.len, null) == null) return MMAP_DEFAULT; + var end: [*c]u8 = null; + const parsed = c.strtoll(&buf, &end, 10); + if (end == @as([*c]u8, &buf) or end[0] != 0) { + // Malformed — fall back to default rather than fail the store open. + return MMAP_DEFAULT; + } + if (parsed < 0) return 0; + return parsed; +} + +fn execMmapSize(s: *Store) c_int { + var mmap_sql: [ST_BUF_64]u8 = undefined; + _ = interop.bprint(&mmap_sql, "PRAGMA mmap_size = {d};", .{cbm_store_resolve_mmap_size()}); + return execSql(s, &mmap_sql); +} + +/// Configure connection pragmas. +/// in_memory — :memory: DB (synchronous OFF, no journal file). +/// read_only — query-only connection opened SQLITE_OPEN_READONLY. Runs +/// ONLY non-writing pragmas (foreign_keys, temp_store, +/// busy_timeout, mmap_size) and SKIPS journal_mode=WAL, +/// wal_checkpoint and synchronous — those WRITE to the DB/WAL +/// and would (a) mutate the DB on every read query and (b) +/// fail on a read-only DB file / filesystem. +/// in_memory and read_only are mutually exclusive. +fn configurePragmas(s: *Store, in_memory: bool, read_only: bool) c_int { + var rc = execSql(s, "PRAGMA foreign_keys = ON;"); + if (rc != OK) return rc; + rc = execSql(s, "PRAGMA temp_store = MEMORY;"); + if (rc != OK) return rc; + + if (in_memory) return execSql(s, "PRAGMA synchronous = OFF;"); + + rc = execSql(s, "PRAGMA busy_timeout = 10000;"); + if (rc != OK) return rc; + // A read-only connection runs non-writing pragmas only — see the + // function comment. + if (!read_only) { + rc = execSql(s, "PRAGMA journal_mode = WAL;"); + if (rc != OK) return rc; + // Recover stale WAL from previous crash (best-effort). + // PASSIVE never blocks readers and never ftruncates. + // May fail with SQLITE_BUSY if another process holds a lock. + _ = c.sqlite3_exec(s.db, "PRAGMA wal_checkpoint(PASSIVE)", null, null, null); + rc = execSql(s, "PRAGMA synchronous = NORMAL;"); + if (rc != OK) return rc; + } + return execMmapSize(s); +} + +// ── camelCase splitter for FTS5 indexing ───────────────────────── + +// Emits the original identifier plus a space-separated split version, so FTS5's +// whitespace tokenizer produces both `updateCloudClient` (exact match) and the +// word tokens `update`, `cloud`, `client`. Rules: +// 1. insert space before an uppercase letter preceded by a lowercase letter +// ("updateCloud" → "update Cloud") +// 2. insert space before an uppercase letter preceded by another uppercase +// but followed by a lowercase letter ("XMLParser" → "XML Parser", not +// "X M L Parser") +// snake_case is already split by FTS5's unicode61 tokenizer on `_`. +const CAMEL_SPLIT_BUF = 2048; +const CAMEL_BUF_GUARD = 2; // reserve room for inserted space + NUL terminator. + +/// Denominator epsilon guard for double-precision cosine. +const CBM_STORE_DENOM_EPS_D = 1e-10; +const CBM_STORE_DENOM_EPS_F: f32 = 1e-10; +const CBM_STORE_INT8_MAX: f32 = 127.0; + +const SqlValues = [*c]?*c.sqlite3_value; + +/// True if we should insert a space BEFORE input[i] to split a camelCase word +/// boundary (lowercase→uppercase or uppercase-run→uppercase-then-lowercase). +fn camelShouldSplit(input: [:0]const u8, i: usize) bool { + if (i == 0) return false; + const curr = input[i]; + const prev = input[i - 1]; + const next = input[i + 1]; + if (!std.ascii.isUpper(curr)) return false; + return std.ascii.isLower(prev) or (std.ascii.isUpper(prev) and std.ascii.isLower(next)); +} + +fn sqliteCamelSplit(ctx: ?*c.sqlite3_context, argc: c_int, argv: SqlValues) callconv(.c) void { + _ = argc; + const input_c: CStr = c.sqlite3_value_text(argv[0]); + if (input_c == null or input_c[0] == 0) { + c.sqlite3_result_text(ctx, safeStr(input_c), -1, transient()); + return; + } + const input = std.mem.span(input_c); + var buf: [CAMEL_SPLIT_BUF]u8 = undefined; + if (input.len + 1 >= buf.len) { + // Input too long — fall back to the original string unmodified. + c.sqlite3_result_text(ctx, input_c, -1, transient()); + return; + } + @memcpy(buf[0..input.len], input); + buf[input.len] = ' '; + var len = input.len + 1; + for (input, 0..) |ch, i| { + if (len >= buf.len - CAMEL_BUF_GUARD) break; + if (camelShouldSplit(input, i)) { + buf[len] = ' '; + len += 1; + } + buf[len] = ch; + len += 1; + } + c.sqlite3_result_text(ctx, &buf, @intCast(len), transient()); +} + +// ── REGEXP function for SQLite ─────────────────────────────────── + +/// Destructor passed to sqlite3_set_auxdata — frees the cached compiled regex. +fn regexFreeCb(p: ?*anyopaque) callconv(.c) void { + const re: *c.cbm_regex_t = @ptrCast(@alignCast(p orelse return)); + c.cbm_regfree(re); + c.free(re); +} + +/// Cache the compiled regex on argument slot 0 for the lifetime of the statement. +/// sqlite3_get_auxdata returns the cached pointer on subsequent rows (same parameter +/// value), so cbm_regcomp is called exactly once per statement instead of once per row. +fn regexpWithFlags(ctx: ?*c.sqlite3_context, argv: SqlValues, flags: c_int) void { + const pattern: CStr = c.sqlite3_value_text(argv[0]); + const text: CStr = c.sqlite3_value_text(argv[1]); + if (pattern == null or text == null) { + c.sqlite3_result_int(ctx, 0); + return; + } + + var re: ?*c.cbm_regex_t = @ptrCast(@alignCast(c.sqlite3_get_auxdata(ctx, 0))); + if (re == null) { + re = @ptrCast(@alignCast(c.malloc(@sizeOf(c.cbm_regex_t)))); + if (re == null) { + c.sqlite3_result_error_nomem(ctx); + return; + } + if (c.cbm_regcomp(re, pattern, flags) != 0) { + c.free(re); + c.sqlite3_result_error(ctx, "invalid regex", -1); + return; + } + c.sqlite3_set_auxdata(ctx, 0, re, regexFreeCb); + } + + c.sqlite3_result_int(ctx, @intFromBool(c.cbm_regexec(re, text, 0, null, 0) == 0)); +} + +fn sqliteRegexp(ctx: ?*c.sqlite3_context, argc: c_int, argv: SqlValues) callconv(.c) void { + _ = argc; + regexpWithFlags(ctx, argv, c.CBM_REG_EXTENDED | c.CBM_REG_NOSUB); +} + +/// Case-insensitive REGEXP variant — same auxdata caching strategy. +fn sqliteIregexp(ctx: ?*c.sqlite3_context, argc: c_int, argv: SqlValues) callconv(.c) void { + _ = argc; + regexpWithFlags(ctx, argv, c.CBM_REG_EXTENDED | c.CBM_REG_NOSUB | c.CBM_REG_ICASE); +} + +/// Cosine similarity between two int8 BLOB vectors. +/// Returns float in [-1, 1]. Used for vector search at query time. +fn sqliteCosineI8(ctx: ?*c.sqlite3_context, argc: c_int, argv: SqlValues) callconv(.c) void { + _ = argc; + if (c.sqlite3_value_type(argv[0]) != c.SQLITE_BLOB or c.sqlite3_value_type(argv[1]) != c.SQLITE_BLOB) { + c.sqlite3_result_double(ctx, 0.0); + return; + } + const len_a = c.sqlite3_value_bytes(argv[0]); + const len_b = c.sqlite3_value_bytes(argv[1]); + if (len_a != len_b or len_a == 0) { + c.sqlite3_result_double(ctx, 0.0); + return; + } + const len: usize = @intCast(len_a); + const a: [*]const i8 = @ptrCast(c.sqlite3_value_blob(argv[0]).?); + const b: [*]const i8 = @ptrCast(c.sqlite3_value_blob(argv[1]).?); + var dot: i32 = 0; + var mag_a: i32 = 0; + var mag_b: i32 = 0; + for (a[0..len], b[0..len]) |x, y| { + dot +%= @as(i32, x) * @as(i32, y); + mag_a +%= @as(i32, x) * @as(i32, x); + mag_b +%= @as(i32, y) * @as(i32, y); + } + const denom = @sqrt(@as(f64, @floatFromInt(mag_a))) * @sqrt(@as(f64, @floatFromInt(mag_b))); + c.sqlite3_result_double(ctx, if (denom > CBM_STORE_DENOM_EPS_D) @as(f64, @floatFromInt(dot)) / denom else 0.0); +} + +// ── Lifecycle ──────────────────────────────────────────────────── + +/// SQLite authorizer: deny dangerous operations that could be exploited via +/// SQL injection through the Cypher→SQL translation layer. +fn storeAuthorizer(user_data: ?*anyopaque, action: c_int, p3: [*c]const u8, p4: [*c]const u8, p5: [*c]const u8, p6: [*c]const u8) callconv(.c) c_int { + _ = .{ user_data, p3, p4, p5, p6 }; + return switch (action) { + // ATTACH DATABASE could create/read arbitrary files + c.SQLITE_ATTACH, c.SQLITE_DETACH => c.SQLITE_DENY, + else => c.SQLITE_OK, + }; +} + +/// Block ATTACH/DETACH and register the SQL functions the store's queries use. +fn registerFunctions(db: ?*c.sqlite3) void { + // Security: block ATTACH/DETACH to prevent file creation via SQL injection. + // The authorizer runs inside SQLite's query planner — no string-level bypass. + _ = c.sqlite3_set_authorizer(db, storeAuthorizer, null); + + const flags = c.SQLITE_UTF8 | c.SQLITE_DETERMINISTIC; + // REGEXP function (SQLite doesn't have one built-in) + _ = c.sqlite3_create_function(db, "regexp", 2, flags, null, sqliteRegexp, null, null); + // Case-insensitive variant for search with case_sensitive=false + _ = c.sqlite3_create_function(db, "iregexp", 2, flags, null, sqliteIregexp, null, null); + // Int8 cosine similarity for vector search + _ = c.sqlite3_create_function(db, "cbm_cosine_i8", 2, flags, null, sqliteCosineI8, null, null); + // camelCase splitter for FTS5 BM25 indexing + _ = c.sqlite3_create_function(db, "cbm_camel_split", 1, flags, null, sqliteCamelSplit, null, null); +} + +fn storeOpenInternal(path: Str, in_memory: bool) ?*Store { + const s = callocOne(Store) orelse return null; + + var flags: c_int = c.SQLITE_OPEN_READWRITE | c.SQLITE_OPEN_CREATE; + if (in_memory) flags |= c.SQLITE_OPEN_MEMORY; + + if (c.sqlite3_open_v2(path, &s.db, flags, null) != c.SQLITE_OK) { + c.free(s); + return null; + } + + if (!in_memory) s.db_path = interop.heapStrdup(path); + + registerFunctions(s.db); + + if (configurePragmas(s, in_memory, false) != OK or initSchema(s) != OK or createUserIndexes(s) != OK) { + _ = c.sqlite3_close(s.db); + interop.strFree(&s.db_path); + c.free(s); + return null; + } + + return s; +} + +pub export fn cbm_store_open_memory() ?*Store { + return storeOpenInternal(":memory:", true); +} + +fn storeCleanupPending(db_path: Str) bool { + var marker: [2048]u8 = undefined; + const n = interop.bprint(&marker, "{s}.cbm-cleanup-pending", .{std.mem.span(db_path)}); + if (n == 0 or n >= marker.len) return false; + var marker_stat: c.struct_stat = undefined; + return c.cbm_lstat(&marker, &marker_stat) == 0; +} + +pub export fn cbm_store_db_path(s: ?*const Store) ?Str { + return if (s) |store| store.db_path else null; +} + +/// Build a SQLite "file:" URI with immutable=1 from a filesystem path. +/// immutable=1 bypasses WAL and locking and reads the main DB file directly — +/// used only as a fallback for read-only filesystems where the wal-index +/// (-shm) cannot be created. URI-special characters are percent-encoded. +/// Windows backslashes are normalized to '/', and a leading '/' is inserted +/// for drive-letter paths so the drive is not parsed as a URI authority. +/// Returns false if the output buffer is too small. +fn buildImmutableUri(path: []const u8, out: []u8) bool { + const prefix = "file://"; + const suffix = "?immutable=1"; + const hex = "0123456789ABCDEF"; + if (prefix.len + 1 > out.len) return false; + @memcpy(out[0..prefix.len], prefix); + var pos: usize = prefix.len; + + // Ensure the path component begins with '/' (POSIX absolute paths already + // do; Windows "C:\..." gets a leading '/' -> "/C:/..."). + if (path.len == 0 or path[0] != '/') { + if (pos + 1 >= out.len) return false; + out[pos] = '/'; + pos += 1; + } + + for (path) |raw| { + const ch: u8 = if (raw == '\\') '/' else raw; // normalize Windows separators + const safe = std.ascii.isAlphanumeric(ch) or + ch == '/' or ch == '.' or ch == '-' or ch == '_' or ch == '~' or ch == ':'; + if (safe) { + if (pos + 1 >= out.len) return false; + out[pos] = ch; + pos += 1; + } else { + if (pos + 3 >= out.len) return false; + out[pos] = '%'; + out[pos + 1] = hex[ch >> 4]; + out[pos + 2] = hex[ch & 0xF]; + pos += 3; + } + } + + if (pos + suffix.len + 1 > out.len) return false; + @memcpy(out[pos..][0..suffix.len], suffix); + out[pos + suffix.len] = 0; + return true; +} + +pub export fn cbm_store_open_path_query(db_path_c: ?Str) ?*Store { + const db_path = db_path_c orelse return null; + if (storeCleanupPending(db_path)) return null; + + const s = callocOne(Store) orelse return null; + + // Query tools open the project DB READ-ONLY: a read query must never + // mutate the DB (the previous READWRITE open + WAL write-pragmas did), + // and must work on a read-only DB file / filesystem. + // + // Try a plain READONLY open first — on a normal writable filesystem this + // reads WAL frames correctly via the -shm wal-index. SQLite opens lazily, + // so a read-only-filesystem failure (cannot create -shm for a WAL-mode + // DB) surfaces on first access, not at open time; we probe with a trivial + // read to force it. If the probe fails, retry once with an immutable URI + // that bypasses WAL and reads the main DB file directly. + // + // No SQLITE_OPEN_CREATE on either path — a missing DB must return NULL + // (no ghost .db for unknown/unindexed projects). + var rc = c.sqlite3_open_v2(db_path, &s.db, c.SQLITE_OPEN_READONLY, null); + if (rc == c.SQLITE_OK) { + // Wait out a concurrent writer before probing: a busy probe is not a + // read-only filesystem, and the immutable fallback below ignores the + // WAL, so it would serve a stale snapshot without recent commits. + _ = c.sqlite3_busy_timeout(s.db, ST_BUSY_TIMEOUT_MS); + // Force first DB access so a read-only-FS WAL failure surfaces now. + if (c.sqlite3_exec(s.db, "SELECT 1 FROM sqlite_master LIMIT 1;", null, null, null) != c.SQLITE_OK) { + const busy = (c.sqlite3_errcode(s.db) & 0xFF) == c.SQLITE_BUSY; + _ = c.sqlite3_close(s.db); + s.db = null; + if (busy) { + c.free(s); + return null; + } + rc = c.SQLITE_CANTOPEN; // trigger immutable fallback + } + } + if (rc != c.SQLITE_OK) { + _ = c.sqlite3_close(s.db); // no-op if already NULL + s.db = null; + // A genuinely missing DB must return NULL without creating anything — + // only retry with the immutable URI when the file exists but could not + // be opened (the read-only-filesystem case). This also keeps the + // common "project not found" path to a single open attempt. + var uri: [ST_QUERY_URI_MAX]u8 = undefined; + if (!c.cbm_file_exists(db_path) or !buildImmutableUri(std.mem.span(db_path), &uri)) { + c.free(s); + return null; + } + rc = c.sqlite3_open_v2(&uri, &s.db, c.SQLITE_OPEN_READONLY | c.SQLITE_OPEN_URI, null); + if (rc != c.SQLITE_OK) { + // sqlite3_open_v2 allocates a handle even on failure — must close it. + _ = c.sqlite3_close(s.db); + c.free(s); + return null; + } + } + + s.db_path = interop.heapStrdup(db_path); + + registerFunctions(s.db); + + if (configurePragmas(s, false, true) != OK) { + _ = c.sqlite3_close(s.db); + interop.strFree(&s.db_path); + c.free(s); + return null; + } + + return s; +} + +fn storeStableNumericId(value: CStr) i64 { + var hash: u64 = 1469598103934665603; + if (value != null) { + for (std.mem.span(value)) |byte| hash = (hash ^ byte) *% 1099511628211; + } + return @intCast(hash & std.math.maxInt(i64)); +} + +pub export fn cbm_store_open_zova_workspace_query(db_path: ?Str, workspace_id: ?Str) ?*Store { + if (db_path == null or c.cbm_zova_workspace_id_validate(workspace_id) != 0) return null; + const s = cbm_store_open_path_query(db_path) orelse return null; + // A user-local database can hold several workspaces, and graph hydration + // precedes vector sampling on the same handle. A bounded 256 MiB ceiling + // keeps both working sets resident on large repositories without changing + // compatibility project-store behavior. SQLite allocates pages lazily. + if (c.sqlite3_exec(s.db, "PRAGMA cache_size=-262144", null, null, null) != c.SQLITE_OK) { + cbm_store_close(s); + return null; + } + var project_stmt: ?*Stmt = null; + var project = std.mem.zeroes([256]u8); + if (prepare(s.db, "SELECT p.project FROM cbm_projects_v1 p JOIN cbm_workspace_registry r " ++ + "USING(workspace_key) WHERE r.workspace_id=?1", &project_stmt) != c.SQLITE_OK or + c.sqlite3_bind_text(project_stmt, 1, workspace_id, -1, null) != c.SQLITE_OK or + c.sqlite3_step(project_stmt) != c.SQLITE_ROW) + { + if (project_stmt != null) _ = c.sqlite3_finalize(project_stmt); + cbm_store_close(s); + return null; + } + const project_value = columnText(project_stmt, 0); + if (project_value != null) _ = interop.bprint(&project, "{s}", .{std.mem.span(project_value)}); + _ = c.sqlite3_finalize(project_stmt); + if (project[0] == 0) { + cbm_store_close(s); + return null; + } + s.zova_repository = c.cbm_zova_repository_open(db_path, &project); + if (s.zova_repository == null or !streq(c.cbm_zova_repository_workspace_id(s.zova_repository), workspace_id)) { + cbm_store_close(s); + return null; + } + s.zova_graph_session = c.cbm_zova_graph_session_open(db_path); + if (s.zova_graph_session == null) { + cbm_store_close(s); + return null; + } + _ = interop.bprint(&s.zova_graph_workspace_id, "{s}", .{std.mem.span(workspace_id.?)}); + s.zova_workspace_query = true; + return s; +} + +pub export fn cbm_store_zova_native_topology_ops(s: ?*const Store) c_int { + return if (s) |store| store.zova_native_topology_ops else 0; +} + +// ── Integrity check ────────────────────────────────────────────── + +fn finalizeStmt(slot: *?*Stmt) void { + if (slot.*) |stmt| { + _ = c.sqlite3_finalize(stmt); + slot.* = null; + } +} + +pub export fn cbm_store_open_backend(backend: ?*const c.cbm_store_backend_t, ctx: ?*anyopaque) ?*Store { + if (backend == null) return null; + const s = callocOne(Store) orelse return null; + s.backend = backend; + s.backend_ctx = ctx; + return s; +} + +pub export fn cbm_store_close(s_c: ?*Store) void { + const s = s_c orelse return; + if (s.backend != null) { + c.free(s); + return; + } + + storeCloseZovaVectorSession(s); + storeCloseZovaGraphSession(s); + storeClearZovaGraphWorkspace(s); + c.cbm_zova_repository_close(s.zova_repository); + s.zova_repository = null; + c.free(s.zova_legacy_project); + s.zova_legacy_project = null; + c.free(@ptrCast(s.zova_edges)); + for (s.zovaEdgeTypes()) |edge_type| c.free(edge_type); + c.free(@ptrCast(s.zova_edge_types)); + + // Checkpoint WAL before close to prevent orphan WAL accumulation. + // Best-effort — silently skips if concurrent reader holds a lock. + if (s.db != null and s.db_path != null) { + _ = c.sqlite3_wal_checkpoint_v2(s.db, null, c.SQLITE_CHECKPOINT_PASSIVE, null, null); + } + + // Finalize all cached statements + inline for (@typeInfo(Stmts).@"struct".field_names) |name| finalizeStmt(&@field(s.stmts, name)); + + // Use sqlite3_close_v2 — auto-deallocates when last statement finalizes. + // Prevents ASan false-positive leaks from sqlite3 internal state. + _ = c.sqlite3_close_v2(s.db); + interop.strFree(&s.db_path); + c.free(s); +} + +pub export fn cbm_store_get_db(s: ?*Store) ?*c.sqlite3 { + return if (s) |store| store.db else null; +} + +pub export fn cbm_store_exec(s: ?*Store, sql: ?Str) c_int { + return execSql(s, sql); +} + +pub export fn cbm_store_error(s: ?*Store) ?Str { + return if (s) |store| @ptrCast(&store.errbuf) else "null store"; +} + +// ── Transaction ────────────────────────────────────────────────── + +pub export fn cbm_store_begin(s: ?*Store) c_int { + return execSql(s, "BEGIN IMMEDIATE;"); +} + +pub export fn cbm_store_commit(s: ?*Store) c_int { + return execSql(s, "COMMIT;"); +} + +pub export fn cbm_store_rollback(s: ?*Store) c_int { + return execSql(s, "ROLLBACK;"); +} + +// ── Bulk write ─────────────────────────────────────────────────── + +pub export fn cbm_store_begin_bulk(s: ?*Store) c_int { + // Stay in WAL mode throughout. Switching to MEMORY journal mode would + // make the database unrecoverable if the process crashes mid-write, + // because the in-memory rollback journal is lost on crash. + // WAL mode is crash-safe: uncommitted WAL entries are simply discarded + // on the next open. Performance is preserved via synchronous=OFF and a + // larger cache, which are safe with WAL. + const rc = execSql(s, "PRAGMA synchronous = OFF;"); + if (rc != OK) return rc; + return execSql(s, "PRAGMA cache_size = -65536;"); // 64 MB +} + +pub export fn cbm_store_end_bulk(s: ?*Store) c_int { + const rc = execSql(s, "PRAGMA synchronous = NORMAL;"); + if (rc != OK) return rc; + return execSql(s, "PRAGMA cache_size = -2000;"); // default ~2 MB +} + +pub export fn cbm_store_drop_indexes(s: ?*Store) c_int { + return execSql(s, "DROP INDEX IF EXISTS idx_nodes_label;" ++ + "DROP INDEX IF EXISTS idx_nodes_name;" ++ + "DROP INDEX IF EXISTS idx_nodes_file;" ++ + "DROP INDEX IF EXISTS idx_edges_source;" ++ + "DROP INDEX IF EXISTS idx_edges_target;" ++ + "DROP INDEX IF EXISTS idx_edges_type;" ++ + "DROP INDEX IF EXISTS idx_edges_target_type;" ++ + "DROP INDEX IF EXISTS idx_edges_source_type;"); +} + +pub export fn cbm_store_create_indexes(s: ?*Store) c_int { + return createUserIndexes(s); +} + +// ── Result arrays ──────────────────────────────────────────────── + +/// The C store's growable result array: malloc(cap), then `cap *= ST_GROWTH` +/// through safe_realloc when full. `items` is what the caller receives. +fn Rows(comptime T: type) type { + return struct { + items: [*c]T, + len: c_int = 0, + cap: c_int, + + const Self = @This(); + + /// An array that stays NULL until the first add, which allocates + /// ST_INIT_CAP_16 slots. + const lazy: Self = .{ .items = null, .cap = 0 }; + + fn init(cap: c_int) Self { + return .{ .items = interop.mallocN(T, @intCast(cap)), .cap = cap }; + } + + /// The next free slot, uninitialized. + fn add(self: *Self) *T { + if (self.len >= self.cap) { + self.cap = if (self.cap != 0) self.cap * ST_GROWTH else ST_INIT_CAP_16; + self.items = interop.safeReallocN(T, self.items, @intCast(self.cap)); + } + self.len += 1; + return self.at(self.len - 1); + } + + fn at(self: *const Self, index: c_int) *T { + return &@as([*]T, self.items)[@intCast(index)]; + } + + fn slice(self: *const Self) []T { + if (self.len == 0) return &.{}; + return @as([*]T, self.items)[0..@intCast(self.len)]; + } + }; +} + +fn Span(comptime P: type) type { + const info = @typeInfo(P).pointer; + return if (info.attrs.@"const") []const info.child else []info.child; +} + +/// The items of a (C pointer, count) array; empty for NULL or count <= 0. +fn span(items: anytype, count: c_int) Span(@TypeOf(items)) { + if (items == null or count <= 0) return &.{}; + return items[0..@intCast(count)]; +} + +/// Runs a cached statement that binds text parameters and returns no rows. +/// `what` names the operation in the store's error text. +fn execCached(s_c: ?*Store, comptime slot: []const u8, sql: CStr, binds: []const CStr, comptime what: []const u8) c_int { + const s = s_c orelse return ERR; + const stmt = prepareCached(s, &@field(s.stmts, slot), sql) orelse return ERR; + for (binds, 1..) |value, col| _ = bindText(stmt, @intCast(col), value); + if (c.sqlite3_step(stmt) != c.SQLITE_DONE) { + storeSetErrorSqlite(s, what); + return ERR; + } + return OK; +} + +/// (a > b) - (a < b). +fn order(a: anytype, b: @TypeOf(a)) c_int { + return @as(c_int, @intFromBool(a > b)) - @intFromBool(a < b); +} + +/// True when queries are answered by the Zova workspace repository. +fn usesRepository(s: *const Store) bool { + return s.zova_workspace_query and s.zova_repository != null; +} + +// ── Project CRUD ───────────────────────────────────────────────── + +pub export fn cbm_store_upsert_project(s_c: ?*Store, name: CStr, root_path: CStr) c_int { + const s = s_c orelse return ERR; + const stmt = prepareCached(s, &s.stmts.upsert_project, "INSERT INTO projects (name, indexed_at, root_path) VALUES (?1, ?2, ?3) " ++ + "ON CONFLICT(name) DO UPDATE SET indexed_at=?2, root_path=?3;") orelse return ERR; + + var ts: [c.CBM_SZ_64]u8 = undefined; + isoNow(&ts); + + _ = bindText(stmt, 1, name); + _ = bindText(stmt, 2, &ts); + _ = bindText(stmt, 3, root_path); + + if (c.sqlite3_step(stmt) != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "upsert_project"); + return ERR; + } + return OK; +} + +fn scanProject(stmt: ?*Stmt, p: *c.cbm_project_t) void { + p.name = columnDup(stmt, 0); + p.indexed_at = columnDup(stmt, 1); + p.root_path = columnDup(stmt, 2); +} + +pub export fn cbm_store_get_project(s_c: ?*Store, name: CStr, out: *c.cbm_project_t) c_int { + const s = s_c orelse return ERR; + if (usesRepository(s)) { + return c.cbm_zova_repository_get_project(s.zova_repository, &s.zova_graph_workspace_id, out); + } + const stmt = prepareCached(s, &s.stmts.get_project, "SELECT name, indexed_at, root_path FROM projects WHERE name = ?1;") orelse return ERR; + + _ = bindText(stmt, 1, name); + if (c.sqlite3_step(stmt) == c.SQLITE_ROW) { + scanProject(stmt, out); + return OK; + } + return NOT_FOUND; +} + +pub export fn cbm_store_list_projects(s_c: ?*Store, out: *[*c]c.cbm_project_t, count: *c_int) c_int { + const s = s_c orelse return ERR; + const stmt = prepareCached(s, &s.stmts.list_projects, "SELECT name, indexed_at, root_path FROM projects ORDER BY name;") orelse return ERR; + + // Collect into dynamic array + var rows = Rows(c.cbm_project_t).init(ST_INIT_CAP_8); + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) scanProject(stmt, rows.add()); + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + cbm_store_free_projects(rows.items, rows.len); + out.* = null; + count.* = 0; + return ERR; + } + + out.* = rows.items; + count.* = rows.len; + return OK; +} + +pub export fn cbm_store_delete_project(s: ?*Store, name: CStr) c_int { + return execCached(s, "delete_project", "DELETE FROM projects WHERE name = ?1;", &.{name}, "delete_project"); +} + +// ── Node CRUD ──────────────────────────────────────────────────── + +const node_columns = "SELECT id, project, label, name, qualified_name, file_path, " ++ + "start_line, end_line, properties FROM nodes "; + +pub export fn cbm_store_upsert_node(s_c: ?*Store, n: *const c.cbm_node_t) i64 { + const s = s_c orelse return ERR; + const stmt = prepareCached(s, &s.stmts.upsert_node, "INSERT INTO nodes (project, label, name, qualified_name, file_path, " ++ + "start_line, end_line, properties) " ++ + "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) " ++ + "ON CONFLICT(project, qualified_name) DO UPDATE SET " ++ + "label=?2, name=?3, file_path=?5, start_line=?6, end_line=?7, properties=?8 " ++ + "RETURNING id;") orelse return ERR; + + _ = bindText(stmt, 1, safeStr(n.project)); + _ = bindText(stmt, 2, safeStr(n.label)); + _ = bindText(stmt, 3, safeStr(n.name)); + _ = bindText(stmt, 4, safeStr(n.qualified_name)); + _ = bindText(stmt, 5, safeStr(n.file_path)); + _ = c.sqlite3_bind_int(stmt, 6, n.start_line); + _ = c.sqlite3_bind_int(stmt, 7, n.end_line); + _ = bindText(stmt, 8, safeProps(n.properties_json)); + + if (c.sqlite3_step(stmt) == c.SQLITE_ROW) { + const id = c.sqlite3_column_int64(stmt, 0); + _ = c.sqlite3_reset(stmt); // unblock COMMIT — RETURNING leaves stmt active + return id; + } + _ = c.sqlite3_reset(stmt); + storeSetErrorSqlite(s, "upsert_node"); + return ERR; +} + +/// Scan a node from current row of stmt. Heap-allocates strings. +fn scanNode(stmt: ?*Stmt, n: *c.cbm_node_t) void { + n.id = c.sqlite3_column_int64(stmt, 0); + n.project = columnDup(stmt, 1); + n.label = columnDup(stmt, 2); + n.name = columnDup(stmt, 3); + n.qualified_name = columnDup(stmt, 4); + n.file_path = columnDup(stmt, 5); + n.start_line = c.sqlite3_column_int(stmt, 6); + n.end_line = c.sqlite3_column_int(stmt, 7); + n.properties_json = columnDup(stmt, 8); +} + +/// Steps stmt to completion, scanning every row into a malloc'd node array. +fn collectNodes(s: *Store, stmt: ?*Stmt, cap: c_int, out: *[*c]c.cbm_node_t, count: *c_int) c_int { + var rows = Rows(c.cbm_node_t).init(cap); + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) scanNode(stmt, rows.add()); + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + cbm_store_free_nodes(rows.items, rows.len); + out.* = null; + count.* = 0; + return ERR; + } + out.* = rows.items; + count.* = rows.len; + return OK; +} + +pub export fn cbm_store_find_node_by_id(s_c: ?*Store, id: i64, out: *c.cbm_node_t) c_int { + const s = s_c orelse return ERR; + if (s.backend) |backend| return backend.find_node_by_id.?(s.backend_ctx, id, out); + if (usesRepository(s)) { + return c.cbm_zova_repository_find_node_by_numeric_id(s.zova_repository, &s.zova_graph_workspace_id, id, out); + } + const stmt = prepareCached(s, &s.stmts.find_node_by_id, node_columns ++ "WHERE id = ?1;") orelse return ERR; + + _ = c.sqlite3_bind_int64(stmt, 1, id); + if (c.sqlite3_step(stmt) == c.SQLITE_ROW) { + scanNode(stmt, out); + return OK; + } + return NOT_FOUND; +} + +pub export fn cbm_store_find_node_by_qn(s_c: ?*Store, project: CStr, qn: CStr, out: *c.cbm_node_t) c_int { + const s = s_c orelse return ERR; + if (s.db == null) return ERR; + if (usesRepository(s)) { + return c.cbm_zova_repository_find_node_by_qn(s.zova_repository, &s.zova_graph_workspace_id, qn, out); + } + const stmt = prepareCached(s, &s.stmts.find_node_by_qn, node_columns ++ + "WHERE project = ?1 AND qualified_name = ?2;") orelse return ERR; + + _ = bindText(stmt, 1, project); + _ = bindText(stmt, 2, qn); + if (c.sqlite3_step(stmt) == c.SQLITE_ROW) { + scanNode(stmt, out); + return OK; + } + return NOT_FOUND; +} + +pub export fn cbm_store_find_node_by_qn_any(s_c: ?*Store, qn: CStr, out: *c.cbm_node_t) c_int { + const s = s_c orelse return ERR; + if (s.db == null) return ERR; + if (usesRepository(s)) { + return c.cbm_zova_repository_find_node_by_qn(s.zova_repository, &s.zova_graph_workspace_id, qn, out); + } + const stmt = prepareCached(s, &s.stmts.find_node_by_qn_any, node_columns ++ + "WHERE qualified_name = ?1 LIMIT 1;") orelse return ERR; + + _ = bindText(stmt, 1, qn); + if (c.sqlite3_step(stmt) == c.SQLITE_ROW) { + scanNode(stmt, out); + return OK; + } + return NOT_FOUND; +} + +pub export fn cbm_store_find_nodes_by_name_any(s_c: ?*Store, name: CStr, out: *[*c]c.cbm_node_t, count: *c_int) c_int { + out.* = null; + count.* = 0; + const s = s_c orelse return ERR; + const stmt = prepareCached(s, &s.stmts.find_nodes_by_name_any, node_columns ++ "WHERE name = ?1;") orelse return ERR; + + _ = bindText(stmt, 1, name); + return collectNodes(s, stmt, ST_INIT_CAP_16, out, count); +} + +pub export fn cbm_store_find_node_ids_by_qns(s: ?*Store, project: CStr, qns_c: ?[*]CStr, qn_count: c_int, out_ids_c: ?[*]i64) c_int { + const qns = qns_c orelse return 0; + const out_ids = out_ids_c orelse return 0; + if (s == null or project == null or qn_count <= 0) return 0; + const n: usize = @intCast(qn_count); + + // Zero out results + @memset(out_ids[0..n], 0); + + var found: c_int = 0; + var node = std.mem.zeroes(c.cbm_node_t); + for (qns[0..n], out_ids[0..n]) |qn, *out_id| { + if (qn == null) continue; + if (cbm_store_find_node_by_qn(s, project, qn, &node) == OK) { + out_id.* = node.id; + found += 1; + cbm_node_free_fields(&node); + node = std.mem.zeroes(c.cbm_node_t); + } + } + return found; +} + +/// Generic: find multiple nodes by a single-column filter. +fn findNodesGeneric(s_c: ?*Store, comptime slot: []const u8, sql: CStr, project: CStr, val: CStr, out: *[*c]c.cbm_node_t, count: *c_int) c_int { + const s = s_c orelse { + out.* = null; + count.* = 0; + return ERR; + }; + if (s.db == null) { + out.* = null; + count.* = 0; + return ERR; + } + const stmt = prepareCached(s, &@field(s.stmts, slot), sql) orelse return ERR; + + _ = bindText(stmt, 1, project); + if (val != null) _ = bindText(stmt, 2, val); + + return collectNodes(s, stmt, ST_INIT_CAP_16, out, count); +} + +fn nodePublicOrderCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + const left: *const c.cbm_node_t = @ptrCast(@alignCast(left_ptr)); + const right: *const c.cbm_node_t = @ptrCast(@alignCast(right_ptr)); + var cmp = c.strcmp(safeStr(left.qualified_name), safeStr(right.qualified_name)); + if (cmp == 0) cmp = c.strcmp(safeStr(left.file_path), safeStr(right.file_path)); + if (cmp == 0) cmp = order(left.start_line, right.start_line); + if (cmp == 0) cmp = order(left.end_line, right.end_line); + if (cmp == 0) cmp = order(left.id, right.id); + return cmp; +} + +/// Runs a search on the store's backend or Zova repository and moves the +/// matching nodes out of the results. `keep` is the node field that must equal +/// `value`, or null to keep every result. +fn findNodesBySearch(s: *Store, params: *const c.cbm_search_params_t, comptime keep: ?[]const u8, value: CStr, out: *[*c]c.cbm_node_t, count: *c_int) c_int { + var search = std.mem.zeroes(c.cbm_search_output_t); + const rc = if (s.backend) |backend| + backend.search.?(s.backend_ctx, params, &search) + else + c.cbm_zova_repository_search(s.zova_repository, &s.zova_graph_workspace_id, params, &search); + if (rc != OK) { + out.* = null; + count.* = 0; + return rc; + } + const nodes: [*c]c.cbm_node_t = if (search.count != 0) interop.callocN(c.cbm_node_t, @intCast(search.count)) else null; + if (search.count != 0 and nodes == null) { + cbm_store_search_free(&search); + return ERR; + } + var matched: usize = 0; + for (span(search.results, search.count)) |*result| { + if (keep) |field| { + const actual: CStr = @field(result.node, field); + if (actual == null or !streq(actual, value)) continue; + } + nodes[matched] = result.node; + matched += 1; + result.node = std.mem.zeroes(c.cbm_node_t); + } + out.* = nodes; + count.* = @intCast(matched); + cbm_store_search_free(&search); + return OK; +} + +fn searchAllParams(project: CStr) c.cbm_search_params_t { + return .{ .project = project, .min_degree = -1, .max_degree = -1, .limit = std.math.maxInt(c_int) }; +} + +pub export fn cbm_store_find_nodes_by_name(s_c: ?*Store, project: CStr, name: CStr, out: *[*c]c.cbm_node_t, count: *c_int) c_int { + if (s_c) |s| { + if (s.backend != null or usesRepository(s)) { + var params = searchAllParams(project); + params.name_pattern = name; + return findNodesBySearch(s, ¶ms, "name", name, out, count); + } + } + return findNodesGeneric(s_c, "find_nodes_by_name", node_columns ++ + "WHERE project = ?1 AND name = ?2;", project, name, out, count); +} + +/// label NULL matches every label. +pub export fn cbm_store_find_nodes_by_label(s_c: ?*Store, project: CStr, label: CStr, out: *[*c]c.cbm_node_t, count: *c_int) c_int { + if (s_c) |s| { + if (s.backend != null or usesRepository(s)) { + var params = searchAllParams(project); + params.label = label; + const rc = findNodesBySearch(s, ¶ms, null, null, out, count); + if (rc == OK and count.* > 1) { + c.qsort(out.*, @intCast(count.*), @sizeOf(c.cbm_node_t), nodePublicOrderCompare); + } + return rc; + } + } + if (label == null) { + return findNodesGeneric(s_c, "find_nodes", node_columns ++ + "WHERE project = ?1 " ++ + "ORDER BY qualified_name,file_path,start_line,end_line,id;", project, null, out, count); + } + return findNodesGeneric(s_c, "find_nodes_by_label", node_columns ++ + "WHERE project = ?1 AND label = ?2 " ++ + "ORDER BY qualified_name,file_path,start_line,end_line,id;", project, label, out, count); +} + +pub export fn cbm_store_find_nodes(s: ?*Store, project: CStr, out: *[*c]c.cbm_node_t, count: *c_int) c_int { + return cbm_store_find_nodes_by_label(s, project, null, out, count); +} + +pub export fn cbm_store_find_nodes_by_file(s_c: ?*Store, project: CStr, file_path: CStr, out: *[*c]c.cbm_node_t, count: *c_int) c_int { + if (s_c) |s| { + if (usesRepository(s)) { + var params = searchAllParams(project); + params.file_pattern = file_path; + return findNodesBySearch(s, ¶ms, "file_path", file_path, out, count); + } + } + return findNodesGeneric(s_c, "find_nodes_by_file", node_columns ++ + "WHERE project = ?1 AND file_path = ?2;", project, file_path, out, count); +} + +fn fileOutlineIsContainer(label: CStr) bool { + const containers = [_]Str{ "Module", "Package", "File", "Folder", "Project" }; + for (containers) |container| { + if (streq(label, container)) return true; + } + return false; +} + +fn fileOutlineNodeCmp(pa: ?*const anyopaque, pb: ?*const anyopaque) callconv(.c) c_int { + const a = @as(*const *const c.cbm_node_t, @ptrCast(@alignCast(pa))).*; + const b = @as(*const *const c.cbm_node_t, @ptrCast(@alignCast(pb))).*; + if (a.start_line != b.start_line) return if (a.start_line < b.start_line) -1 else 1; + if (a.end_line != b.end_line) return if (a.end_line < b.end_line) -1 else 1; + var cmp = c.strcmp(safeStr(a.label), safeStr(b.label)); + if (cmp == 0) cmp = c.strcmp(safeStr(a.name), safeStr(b.name)); + if (cmp == 0) cmp = c.strcmp(safeStr(a.qualified_name), safeStr(b.qualified_name)); + if (cmp == 0 and a.id != b.id) cmp = if (a.id < b.id) -1 else 1; + return cmp; +} + +pub export fn cbm_store_get_file_outline( + s: ?*Store, + project: CStr, + file_path: CStr, + labels_c: ?[*]const CStr, + label_count: c_int, + limit: c_int, + offset: c_int, + out_c: ?*[*c]c.cbm_file_outline_row_t, + count_c: ?*c_int, + total_c: ?*c_int, +) c_int { + const out = out_c orelse return ERR; + const count = count_c orelse return ERR; + const total = total_c orelse return ERR; + out.* = null; + count.* = 0; + total.* = 0; + if (s == null or project == null or file_path == null or file_path[0] == 0 or limit < 1 or + limit > c.CBM_STORE_FILE_OUTLINE_MAX_LIMIT or offset < 0 or label_count < 0 or + label_count > c.CBM_STORE_FILE_OUTLINE_MAX_LABELS or (label_count > 0 and labels_c == null)) + { + return ERR; + } + const labels: []const CStr = if (labels_c) |l| l[0..@intCast(label_count)] else &.{}; + for (labels) |label| { + if (label == null or label[0] == 0) return ERR; + } + + var nodes: [*c]c.cbm_node_t = null; + var node_count: c_int = 0; + var rc = cbm_store_find_nodes_by_file(s, project, file_path, &nodes, &node_count); + if (rc != OK) return rc; + const kept: [*c]*const c.cbm_node_t = if (node_count != 0) interop.callocN(*const c.cbm_node_t, @intCast(node_count)) else null; + if (node_count != 0 and kept == null) { + cbm_store_free_nodes(nodes, node_count); + return ERR; + } + var kept_count: c_int = 0; + for (span(nodes, node_count)) |*node| { + const label = safeStr(node.label); + if (fileOutlineIsContainer(label)) continue; + var wanted = labels.len == 0; + for (labels) |want| { + if (wanted) break; + wanted = streq(label, want); + } + if (wanted) { + kept[@intCast(kept_count)] = node; + kept_count += 1; + } + } + if (kept_count > 1) { + c.qsort(@ptrCast(kept), @intCast(kept_count), @sizeOf(*const c.cbm_node_t), fileOutlineNodeCmp); + } + + const first = @min(offset, kept_count); + const page = @min(kept_count - first, limit); + const page_nodes = span(kept, kept_count)[@intCast(first)..][0..@intCast(page)]; + var text_bytes: usize = 0; + for (page_nodes) |n| { + text_bytes += c.strlen(safeStr(n.name)) + c.strlen(safeStr(n.label)) + c.strlen(safeStr(n.qualified_name)); + } + if (text_bytes > c.CBM_STORE_FILE_OUTLINE_MAX_TEXT_BYTES) { + c.free(@ptrCast(kept)); + cbm_store_free_nodes(nodes, node_count); + return c.CBM_STORE_SCAN_LIMIT; + } + + var rows: [*c]c.cbm_file_outline_row_t = if (page != 0) interop.callocN(c.cbm_file_outline_row_t, @intCast(page)) else null; + rc = if (page != 0 and rows == null) ERR else OK; + if (rc == OK) { + for (page_nodes, span(rows, page), 0..) |n, *row, i| { + row.name = interop.heapStrdup(safeStr(n.name)); + row.label = interop.heapStrdup(safeStr(n.label)); + row.qualified_name = interop.heapStrdup(safeStr(n.qualified_name)); + row.start_line = n.start_line; + row.end_line = n.end_line; + if (row.name == null or row.label == null or row.qualified_name == null) { + cbm_store_free_file_outline(rows, @intCast(i + 1)); + rows = null; + rc = ERR; + break; + } + } + } + c.free(@ptrCast(kept)); + cbm_store_free_nodes(nodes, node_count); + if (rc != OK) return rc; + out.* = rows; + count.* = page; + total.* = kept_count; + return OK; +} + +pub export fn cbm_store_free_file_outline(rows: [*c]c.cbm_file_outline_row_t, count: c_int) void { + if (rows == null) return; + for (span(rows, count)) |*row| { + c.free(@constCast(row.name)); + c.free(@constCast(row.label)); + c.free(@constCast(row.qualified_name)); + } + c.free(rows); +} + +// ── Graph comparison ───────────────────────────────────────────── + +const GraphCmpNode = extern struct { + key_qn: CStr, // qualified_name without the leading "." + row: *const c.cbm_zova_identity_node_t, +}; + +const GraphCmpEdge = extern struct { + source: *const GraphCmpNode, + target: *const GraphCmpNode, + row: *const c.cbm_zova_identity_edge_t, +}; + +const GraphCmpSide = struct { + rows: [*c]c.cbm_zova_identity_node_t = null, + row_count: c_int = 0, + edge_rows: [*c]c.cbm_zova_identity_edge_t = null, + edge_row_count: c_int = 0, + nodes: [*c]GraphCmpNode = null, // identity order + by_key: [*c]GraphCmpNode = null, // node-key order, for edge endpoints + edges: [*c]GraphCmpEdge = null, // identity order +}; + +fn graphCmpStripProject(qn: CStr, project: CStr) CStr { + const n = c.strlen(project); + if (c.strncmp(qn, project, n) == 0) { + if (qn[n] == '.') return qn + n + 1; + if (qn[n] == 0) return qn + n; + } + return qn; +} + +fn graphCmpNodeIdentity(a: *const GraphCmpNode, b: *const GraphCmpNode) c_int { + var cmp = c.strcmp(a.key_qn, b.key_qn); + if (cmp == 0) cmp = c.strcmp(a.row.label, b.row.label); + if (cmp == 0) cmp = c.strcmp(a.row.file_path, b.row.file_path); + return cmp; +} + +fn graphCmpNodeQsort(a: ?*const anyopaque, b: ?*const anyopaque) callconv(.c) c_int { + return graphCmpNodeIdentity(@ptrCast(@alignCast(a)), @ptrCast(@alignCast(b))); +} + +fn graphCmpEdgeIdentity(a: *const GraphCmpEdge, b: *const GraphCmpEdge) c_int { + var cmp = graphCmpNodeIdentity(a.source, b.source); + if (cmp == 0) cmp = graphCmpNodeIdentity(a.target, b.target); + if (cmp == 0) cmp = c.strcmp(a.row.type, b.row.type); + if (cmp == 0) cmp = c.strcmp(a.row.local_name, b.row.local_name); + return cmp; +} + +fn graphCmpEdgeQsort(a: ?*const anyopaque, b: ?*const anyopaque) callconv(.c) c_int { + return graphCmpEdgeIdentity(@ptrCast(@alignCast(a)), @ptrCast(@alignCast(b))); +} + +fn graphCmpKeyBsearch(key: ?*const anyopaque, elem: ?*const anyopaque) callconv(.c) c_int { + const k = @as(*const i64, @ptrCast(@alignCast(key))).*; + const e = @as(*const GraphCmpNode, @ptrCast(@alignCast(elem))).row.node_key; + return order(k, e); +} + +fn graphCmpSideFree(side: *GraphCmpSide) void { + c.cbm_zova_identities_free(side.rows, side.row_count, side.edge_rows, side.edge_row_count); + c.free(side.nodes); + c.free(side.by_key); + c.free(side.edges); + side.* = .{}; +} + +/// Load one side and order its nodes and edges by identity. +fn graphCmpSideLoad(s_c: ?*Store, project: CStr, max_nodes: u64, max_edges: u64, side: *GraphCmpSide, meta: *c.cbm_graph_compare_project_t) c_int { + const s = s_c orelse return ERR; + if (!usesRepository(s) or project == null) return ERR; + var indexed_at: [*c]u8 = null; + if (c.cbm_zova_repository_index_status(s.zova_repository, &s.zova_graph_workspace_id, &meta.generation, &indexed_at) != OK) { + c.free(indexed_at); + return ERR; + } + _ = interop.bprint(&meta.indexed_at, "{s}", .{std.mem.span(safeStr(indexed_at))}); + c.free(indexed_at); + const rc = c.cbm_zova_repository_graph_identities(s.zova_repository, &s.zova_graph_workspace_id, max_nodes, max_edges, &side.rows, &side.row_count, &side.edge_rows, &side.edge_row_count); + if (rc != OK) return rc; + meta.node_count = side.row_count; + meta.edge_count = side.edge_row_count; + const nn: usize = @intCast(side.row_count); + const ne: usize = @intCast(side.edge_row_count); + side.nodes = if (nn != 0) interop.mallocN(GraphCmpNode, nn) else null; + side.by_key = if (nn != 0) interop.mallocN(GraphCmpNode, nn) else null; + side.edges = if (ne != 0) interop.mallocN(GraphCmpEdge, ne) else null; + if ((nn != 0 and (side.nodes == null or side.by_key == null)) or (ne != 0 and side.edges == null)) { + return ERR; + } + const by_key = span(side.by_key, side.row_count); + const nodes = span(side.nodes, side.row_count); + const edges = span(side.edges, side.edge_row_count); + for (span(side.rows, side.row_count), by_key) |*row, *node| { + for (std.mem.span(row.file_path)) |*ch| { + if (ch.* == '\\') ch.* = '/'; + } + node.* = .{ .key_qn = graphCmpStripProject(row.qualified_name, project), .row = row }; + } + // Rows arrive in node-key order, so by_key is already sorted. + @memcpy(nodes, by_key); + c.qsort(nodes.ptr, nn, @sizeOf(GraphCmpNode), graphCmpNodeQsort); + for (span(side.edge_rows, side.edge_row_count), edges) |*row, *edge| { + const source: ?*const GraphCmpNode = @ptrCast(@alignCast(c.bsearch(&row.source_key, by_key.ptr, nn, @sizeOf(GraphCmpNode), graphCmpKeyBsearch))); + const target: ?*const GraphCmpNode = @ptrCast(@alignCast(c.bsearch(&row.target_key, by_key.ptr, nn, @sizeOf(GraphCmpNode), graphCmpKeyBsearch))); + // an edge endpoint outside the generation + edge.* = .{ .source = source orelse return ERR, .target = target orelse return ERR, .row = row }; + } + c.qsort(edges.ptr, ne, @sizeOf(GraphCmpEdge), graphCmpEdgeQsort); + return OK; +} + +fn graphCmpNodeOut(node: *const GraphCmpNode) c.cbm_graph_node_identity_t { + return .{ .qualified_name = node.row.qualified_name, .label = node.row.label, .file_path = node.row.file_path }; +} + +fn graphCmpEmitNode(callback: c.cbm_graph_compare_node_fn, context: ?*anyopaque, added: bool, node: *const GraphCmpNode, total: *u64) bool { + total.* += 1; + const id = graphCmpNodeOut(node); + return if (callback) |call| call(context, added, &id) else true; +} + +fn graphCmpEmitEdge(callback: c.cbm_graph_compare_edge_fn, context: ?*anyopaque, added: bool, edge: *const GraphCmpEdge, total: *u64) bool { + total.* += 1; + const id: c.cbm_graph_edge_identity_t = .{ + .source = graphCmpNodeOut(edge.source), + .target = graphCmpNodeOut(edge.target), + .type = edge.row.type, + .local_name = edge.row.local_name, + }; + return if (callback) |call| call(context, added, &id) else true; +} + +pub export fn cbm_store_compare_graphs( + base_store: ?*Store, + base_project: CStr, + target_store: ?*Store, + target_project: CStr, + scan_limit: u64, + on_node: c.cbm_graph_compare_node_fn, + on_edge: c.cbm_graph_compare_edge_fn, + context: ?*anyopaque, + out_c: ?*c.cbm_graph_compare_result_t, +) c_int { + const out = out_c orelse return ERR; + out.* = std.mem.zeroes(c.cbm_graph_compare_result_t); + var base: GraphCmpSide = .{}; + var target: GraphCmpSide = .{}; + var rc = graphCmpSideLoad(base_store, base_project, scan_limit, scan_limit, &base, &out.base); + if (rc == OK) { + rc = graphCmpSideLoad(target_store, target_project, scan_limit -% @as(u64, @intCast(base.row_count)), scan_limit -% @as(u64, @intCast(base.edge_row_count)), &target, &out.target); + } + var ok = rc == OK; + if (ok) { + const base_nodes = span(base.nodes, base.row_count); + const target_nodes = span(target.nodes, target.row_count); + var i: usize = 0; + var j: usize = 0; + while (ok and (i < base_nodes.len or j < target_nodes.len)) { + const cmp: c_int = if (i >= base_nodes.len) + 1 + else if (j >= target_nodes.len) + -1 + else + graphCmpNodeIdentity(&base_nodes[i], &target_nodes[j]); + if (cmp < 0) { + ok = graphCmpEmitNode(on_node, context, false, &base_nodes[i], &out.nodes_removed_total); + i += 1; + } else if (cmp > 0) { + ok = graphCmpEmitNode(on_node, context, true, &target_nodes[j], &out.nodes_added_total); + j += 1; + } else { + i += 1; + j += 1; + } + } + const base_edges = span(base.edges, base.edge_row_count); + const target_edges = span(target.edges, target.edge_row_count); + i = 0; + j = 0; + while (ok and (i < base_edges.len or j < target_edges.len)) { + const cmp: c_int = if (i >= base_edges.len) + 1 + else if (j >= target_edges.len) + -1 + else + graphCmpEdgeIdentity(&base_edges[i], &target_edges[j]); + if (cmp < 0) { + ok = graphCmpEmitEdge(on_edge, context, false, &base_edges[i], &out.edges_removed_total); + i += 1; + } else if (cmp > 0) { + ok = graphCmpEmitEdge(on_edge, context, true, &target_edges[j], &out.edges_added_total); + j += 1; + } else { + i += 1; + j += 1; + } + } + } + if (rc == OK and !ok) rc = ERR; + graphCmpSideFree(&base); + graphCmpSideFree(&target); + return rc; +} + +/// A Zova workspace store has no legacy nodes and edges tables. The schema, +/// count and architecture queries below read nodes through a temp view over the +/// workspace's rows, whose ids are Zova node keys; edges come from the graph +/// through storeZovaEdges. The store holds one workspace, so project.* is +/// replaced by the name the rows carry. Does nothing for other stores. +fn storeLegacyScope(s_c: ?*Store, project: *CStr) c_int { + const s = s_c orelse return OK; + if (s.db == null or !usesRepository(s)) return OK; + if (s.zova_legacy_project == null) { + var row = std.mem.zeroes(c.cbm_project_t); + if (c.cbm_zova_repository_get_project(s.zova_repository, &s.zova_graph_workspace_id, &row) != OK) { + return ERR; + } + const sql = c.sqlite3_mprintf("CREATE TEMP VIEW nodes AS SELECT n.zova_node_key AS id,%Q AS project,n.label," ++ + "n.name,n.qualified_name,f.file_path,n.start_line,n.end_line,n.properties " ++ + "FROM main.cbm_nodes_v1 n JOIN main.cbm_files_v1 f ON f.file_key=n.file_key " ++ + "WHERE n.workspace_key=(SELECT workspace_key FROM main.cbm_workspace_registry " ++ + "WHERE workspace_id=%Q)", row.name, @as(CStr, &s.zova_graph_workspace_id)); + const rc = if (sql != null) c.sqlite3_exec(s.db, sql, null, null, null) else c.SQLITE_NOMEM; + c.sqlite3_free(sql); + if (rc == c.SQLITE_OK) s.zova_legacy_project = interop.heapStrdup(row.name); + cbm_project_free_fields(&row); + if (s.zova_legacy_project == null) { + storeSetErrorSqlite(s, "zova legacy nodes"); + return ERR; + } + } + project.* = s.zova_legacy_project; + return OK; +} + +fn storeIsZovaWorkspace(s_c: ?*const Store) bool { + const s = s_c orelse return false; + return usesRepository(s); +} + +const ZovaEdgeLoad = struct { + store: *Store, + capacity: c_int = 0, +}; + +fn storeZovaEdgeTypeFind(s: *const Store, edge_type: []const u8) c_int { + for (s.zovaEdgeTypes(), 0..) |name, i| { + if (std.mem.eql(u8, std.mem.span(name), edge_type)) return @intCast(i); + } + return c.CBM_NOT_FOUND; +} + +fn storeZovaEdgeLoad(source_key: i64, target_key: i64, type_c: [*c]const u8, type_len: usize, properties: [*c]const u8, properties_len: usize, context_ptr: ?*anyopaque) callconv(.c) c_int { + _ = .{ properties, properties_len }; + const context: *ZovaEdgeLoad = @ptrCast(@alignCast(context_ptr)); + const s = context.store; + const edge_type: []const u8 = if (type_len == 0) "" else type_c[0..type_len]; + var t = storeZovaEdgeTypeFind(s, edge_type); + if (t < 0) { + const types = interop.reallocN([*c]u8, s.zova_edge_types, @intCast(s.zova_edge_type_count + 1)); + const name: [*c]u8 = if (types != null) interop.mallocN(u8, type_len + 1) else null; + if (types != null) s.zova_edge_types = types; + if (name == null) return c.CBM_NOT_FOUND; + @memcpy(name[0..type_len], edge_type); + name[type_len] = 0; + t = s.zova_edge_type_count; + s.zova_edge_types[@intCast(t)] = name; + s.zova_edge_type_count += 1; + } + if (s.zova_edge_count == context.capacity) { + const capacity = if (context.capacity != 0) context.capacity * ST_GROWTH else c.CBM_SZ_1K; + const edges = interop.reallocN(ZovaEdge, s.zova_edges, @intCast(capacity)); + if (edges == null) return c.CBM_NOT_FOUND; + s.zova_edges = edges; + context.capacity = capacity; + } + s.zova_edges[@intCast(s.zova_edge_count)] = .{ .source_key = source_key, .target_key = target_key, .type = t }; + s.zova_edge_count += 1; + return 0; +} + +/// The workspace's cbm edges, read once per store from the Zova graph in scan +/// order. Schema, count and architecture reads aggregate this list instead of +/// querying an edges table. +fn storeZovaEdges(s: *Store) c_int { + if (s.zova_edges_loaded) return OK; + var context: ZovaEdgeLoad = .{ .store = s }; + if (c.cbm_zova_repository_visit_edges(s.zova_repository, &s.zova_graph_workspace_id, null, storeZovaEdgeLoad, &context) != OK) { + c.free(s.zova_edges); + s.zova_edges = null; + s.zova_edge_count = 0; + storeSetError(s, "zova edges"); + return ERR; + } + s.zova_edges_loaded = true; + return OK; +} + +fn storeIdCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + const left = @as(*const i64, @ptrCast(@alignCast(left_ptr))).*; + const right = @as(*const i64, @ptrCast(@alignCast(right_ptr))).*; + return order(left, right); +} + +/// True when id is in the ascending id list. +fn storeIdIn(ids: []const i64, id: i64) bool { + return ids.len > 0 and c.bsearch(&id, ids.ptr, ids.len, @sizeOf(i64), storeIdCompare) != null; +} + +pub export fn cbm_store_count_nodes(s_c: ?*Store, project_c: CStr) c_int { + const s = s_c orelse return 0; + if (s.db == null) return 0; + var project = project_c; + if (storeLegacyScope(s, &project) != OK) return ERR; + const stmt = prepareCached(s, &s.stmts.count_nodes, "SELECT COUNT(*) FROM nodes WHERE project = ?1;") orelse return ERR; + + _ = bindText(stmt, 1, project); + if (c.sqlite3_step(stmt) == c.SQLITE_ROW) return c.sqlite3_column_int(stmt, 0); + return 0; +} + +pub export fn cbm_store_delete_nodes_by_project(s: ?*Store, project: CStr) c_int { + return execCached(s, "delete_nodes_by_project", "DELETE FROM nodes WHERE project = ?1;", &.{project}, "delete_nodes_by_project"); +} + +pub export fn cbm_store_delete_nodes_by_file(s: ?*Store, project: CStr, file_path: CStr) c_int { + return execCached(s, "delete_nodes_by_file", "DELETE FROM nodes WHERE project = ?1 AND file_path = ?2;", &.{ project, file_path }, "delete_nodes_by_file"); +} + +pub export fn cbm_store_delete_nodes_by_label(s: ?*Store, project: CStr, label: CStr) c_int { + return execCached(s, "delete_nodes_by_label", "DELETE FROM nodes WHERE project = ?1 AND label = ?2;", &.{ project, label }, "delete_nodes_by_label"); +} + +// ── Node batch ─────────────────────────────────────────────────── + +pub export fn cbm_store_upsert_node_batch(s: ?*Store, nodes: [*c]const c.cbm_node_t, count: c_int, out_ids: [*c]i64) c_int { + if (count == 0) return OK; + + _ = execSql(s, "BEGIN IMMEDIATE;"); + for (span(nodes, count), 0..) |*node, i| { + const id = cbm_store_upsert_node(s, node); + if (id == ERR) { + _ = execSql(s, "ROLLBACK;"); + return ERR; + } + if (out_ids != null) out_ids[i] = id; + } + _ = execSql(s, "COMMIT;"); + return OK; +} + +// ── Edge CRUD ──────────────────────────────────────────────────── + +const edge_columns = "SELECT id, project, source_id, target_id, type, properties FROM edges "; + +pub export fn cbm_store_insert_edge(s_c: ?*Store, e: *const c.cbm_edge_t) i64 { + const s = s_c orelse return ERR; + // Conflict target includes local_name_gen (#768) so IMPORTS edges with + // different local_name coexist while re-inserting the same import still + // upserts. Must match the table's UNIQUE constraint in initSchema. + const stmt = prepareCached(s, &s.stmts.insert_edge, "INSERT INTO edges (project, source_id, target_id, type, properties) " ++ + "VALUES (?1, ?2, ?3, ?4, ?5) " ++ + "ON CONFLICT(source_id, target_id, type, local_name_gen) DO UPDATE SET " ++ + "properties = json_patch(properties, ?5) " ++ + "RETURNING id;") orelse return ERR; + + _ = bindText(stmt, 1, safeStr(e.project)); + _ = c.sqlite3_bind_int64(stmt, 2, e.source_id); + _ = c.sqlite3_bind_int64(stmt, 3, e.target_id); + _ = bindText(stmt, 4, safeStr(e.type)); + _ = bindText(stmt, 5, safeProps(e.properties_json)); + + if (c.sqlite3_step(stmt) == c.SQLITE_ROW) { + const id = c.sqlite3_column_int64(stmt, 0); + _ = c.sqlite3_reset(stmt); // unblock COMMIT — RETURNING leaves stmt active + return id; + } + _ = c.sqlite3_reset(stmt); + storeSetErrorSqlite(s, "insert_edge"); + return ERR; +} + +/// Scan an edge from current row of stmt. +fn scanEdge(stmt: ?*Stmt, e: *c.cbm_edge_t) void { + e.id = c.sqlite3_column_int64(stmt, 0); + e.project = columnDup(stmt, 1); + e.source_id = c.sqlite3_column_int64(stmt, 2); + e.target_id = c.sqlite3_column_int64(stmt, 3); + e.type = columnDup(stmt, 4); + e.properties_json = columnDup(stmt, 5); +} + +/// Steps stmt to completion, scanning every row into a malloc'd edge array. +fn collectEdges(s: *Store, stmt: ?*Stmt, cap: c_int, out: *[*c]c.cbm_edge_t, count: *c_int) c_int { + var rows = Rows(c.cbm_edge_t).init(cap); + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) scanEdge(stmt, rows.add()); + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + cbm_store_free_edges(rows.items, rows.len); + out.* = null; + count.* = 0; + return ERR; + } + out.* = rows.items; + count.* = rows.len; + return OK; +} + +/// What an edge query binds: an endpoint id or a project as ?1, then an +/// optional edge type as ?2. +const EdgeBind = struct { + id: ?i64 = null, + project: CStr = null, + type: ?CStr = null, +}; + +/// Generic: find multiple edges by a filter. +fn findEdgesGeneric(s_c: ?*Store, comptime slot: []const u8, sql: CStr, bind: EdgeBind, out: *[*c]c.cbm_edge_t, count: *c_int) c_int { + const s = s_c orelse { + out.* = null; + count.* = 0; + return ERR; + }; + if (s.db == null) { + out.* = null; + count.* = 0; + return ERR; + } + const stmt = prepareCached(s, &@field(s.stmts, slot), sql) orelse return ERR; + + if (bind.id) |id| { + _ = c.sqlite3_bind_int64(stmt, 1, id); + } else { + _ = bindText(stmt, 1, bind.project); + } + if (bind.type) |edge_type| _ = bindText(stmt, 2, edge_type); + + return collectEdges(s, stmt, ST_INIT_CAP_16, out, count); +} + +fn storeZovaWorkspaceEdges(s: *Store, node_numeric_id: i64, direction: CStr, edge_type: CStr, out_c: ?*[*c]c.cbm_edge_t, count_c: ?*c_int) c_int { + const out = out_c orelse return NOT_FOUND; + const count = count_c orelse return NOT_FOUND; + if (!usesRepository(s)) return NOT_FOUND; + out.* = null; + count.* = 0; + var stable = std.mem.zeroes([c.CBM_ZOVA_WORKSPACE_ID_MAX + 96]u8); + var rc = c.cbm_zova_repository_stable_id_for_numeric_id(s.zova_repository, &s.zova_graph_workspace_id, node_numeric_id, &stable, stable.len); + if (rc != OK) return rc; + var edges: [*c]c.cbm_edge_t = null; + var edge_count: c_int = 0; + rc = c.cbm_zova_repository_find_edges(s.zova_repository, &s.zova_graph_workspace_id, &stable, direction, &edges, &edge_count); + if (rc != OK) return rc; + if (edge_type != null) { + var kept: usize = 0; + for (span(edges, edge_count), 0..) |*edge, i| { + if (edge.type != null and streq(edge.type, edge_type)) { + if (kept != i) { + edges[kept] = edge.*; + edge.* = std.mem.zeroes(c.cbm_edge_t); + } + kept += 1; + } else { + interop.strFree(&edge.project); + interop.strFree(&edge.type); + interop.strFree(&edge.properties_json); + } + } + edge_count = @intCast(kept); + } + out.* = edges; + count.* = edge_count; + s.zova_native_topology_ops += 1; + return OK; +} + +pub export fn cbm_store_find_edges_by_source(s_c: ?*Store, source_id: i64, out: *[*c]c.cbm_edge_t, count: *c_int) c_int { + if (s_c) |s| { + if (s.backend) |backend| return backend.find_edges.?(s.backend_ctx, source_id, "outbound", null, out, count); + if (s.zova_workspace_query) return storeZovaWorkspaceEdges(s, source_id, "outbound", null, out, count); + } + return findEdgesGeneric(s_c, "find_edges_by_source", edge_columns ++ "WHERE source_id = ?1;", .{ .id = source_id }, out, count); +} + +pub export fn cbm_store_find_edges_by_target(s_c: ?*Store, target_id: i64, out: *[*c]c.cbm_edge_t, count: *c_int) c_int { + if (s_c) |s| { + if (s.backend) |backend| return backend.find_edges.?(s.backend_ctx, target_id, "inbound", null, out, count); + if (s.zova_workspace_query) return storeZovaWorkspaceEdges(s, target_id, "inbound", null, out, count); + } + return findEdgesGeneric(s_c, "find_edges_by_target", edge_columns ++ "WHERE target_id = ?1;", .{ .id = target_id }, out, count); +} + +pub export fn cbm_store_find_edges_by_source_type(s_c: ?*Store, source_id: i64, edge_type: CStr, out: *[*c]c.cbm_edge_t, count: *c_int) c_int { + if (s_c) |s| { + if (s.backend) |backend| return backend.find_edges.?(s.backend_ctx, source_id, "outbound", edge_type, out, count); + if (s.zova_workspace_query) return storeZovaWorkspaceEdges(s, source_id, "outbound", edge_type, out, count); + } + return findEdgesGeneric(s_c, "find_edges_by_source_type", edge_columns ++ "WHERE source_id = ?1 AND type = ?2;", .{ .id = source_id, .type = edge_type }, out, count); +} + +pub export fn cbm_store_find_edges_by_target_type(s_c: ?*Store, target_id: i64, edge_type: CStr, out: *[*c]c.cbm_edge_t, count: *c_int) c_int { + if (s_c) |s| { + if (s.backend) |backend| return backend.find_edges.?(s.backend_ctx, target_id, "inbound", edge_type, out, count); + if (s.zova_workspace_query) return storeZovaWorkspaceEdges(s, target_id, "inbound", edge_type, out, count); + } + return findEdgesGeneric(s_c, "find_edges_by_target_type", edge_columns ++ "WHERE target_id = ?1 AND type = ?2;", .{ .id = target_id, .type = edge_type }, out, count); +} + +pub export fn cbm_store_find_edges_by_type(s_c: ?*Store, project: CStr, edge_type: CStr, out: *[*c]c.cbm_edge_t, count: *c_int) c_int { + if (s_c) |s| { + if (usesRepository(s)) { + return c.cbm_zova_repository_find_edges_by_type(s.zova_repository, &s.zova_graph_workspace_id, edge_type, out, count); + } + } + return findEdgesGeneric(s_c, "find_edges_by_type", edge_columns ++ "WHERE project = ?1 AND type = ?2;", .{ .project = project, .type = edge_type }, out, count); +} + +pub export fn cbm_store_count_edges(s_c: ?*Store, project: CStr) c_int { + const s = s_c orelse return 0; + if (s.db == null) return 0; + if (usesRepository(s)) { + var nodes: c_int = 0; + var edges: c_int = 0; + return if (c.cbm_zova_repository_counts(s.zova_repository, &s.zova_graph_workspace_id, &nodes, &edges) == OK) edges else ERR; + } + const stmt = prepareCached(s, &s.stmts.count_edges, "SELECT COUNT(*) FROM edges WHERE project = ?1;") orelse return ERR; + + _ = bindText(stmt, 1, project); + if (c.sqlite3_step(stmt) == c.SQLITE_ROW) return c.sqlite3_column_int(stmt, 0); + return 0; +} + +pub export fn cbm_store_count_edges_by_type(s_c: ?*Store, project: CStr, edge_type: CStr) c_int { + const s = s_c orelse return ERR; + if (usesRepository(s)) { + if (storeZovaEdges(s) != OK) return ERR; + const t = if (edge_type != null) storeZovaEdgeTypeFind(s, std.mem.span(edge_type)) else c.CBM_NOT_FOUND; + var n: c_int = 0; + if (t >= 0) { + for (s.zovaEdges()) |edge| n += @intFromBool(edge.type == t); + } + return n; + } + const stmt = prepareCached(s, &s.stmts.count_edges_by_type, "SELECT COUNT(*) FROM edges WHERE project = ?1 AND type = ?2;") orelse return ERR; + + _ = bindText(stmt, 1, project); + _ = bindText(stmt, 2, edge_type); + if (c.sqlite3_step(stmt) == c.SQLITE_ROW) return c.sqlite3_column_int(stmt, 0); + return 0; +} + +pub export fn cbm_store_delete_edges_by_project(s: ?*Store, project: CStr) c_int { + return execCached(s, "delete_edges_by_project", "DELETE FROM edges WHERE project = ?1;", &.{project}, "delete_edges_by_project"); +} + +pub export fn cbm_store_delete_edges_by_type(s: ?*Store, project: CStr, edge_type: CStr) c_int { + return execCached(s, "delete_edges_by_type", "DELETE FROM edges WHERE project = ?1 AND type = ?2;", &.{ project, edge_type }, "delete_edges_by_type"); +} + +// ── Edge batch ─────────────────────────────────────────────────── + +pub export fn cbm_store_insert_edge_batch(s: ?*Store, edges: [*c]const c.cbm_edge_t, count: c_int) c_int { + if (count == 0) return OK; + + _ = execSql(s, "BEGIN IMMEDIATE;"); + for (span(edges, count)) |*edge| { + if (cbm_store_insert_edge(s, edge) == ERR) { + _ = execSql(s, "ROLLBACK;"); + return ERR; + } + } + _ = execSql(s, "COMMIT;"); + return OK; +} + +// ── File hash CRUD ─────────────────────────────────────────────── + +pub export fn cbm_store_upsert_file_hash(s_c: ?*Store, project: CStr, rel_path: CStr, sha256: CStr, mtime_ns: i64, size: i64) c_int { + const s = s_c orelse return ERR; + const stmt = prepareCached(s, &s.stmts.upsert_file_hash, "INSERT INTO file_hashes (project, rel_path, sha256, mtime_ns, size) " ++ + "VALUES (?1, ?2, ?3, ?4, ?5) " ++ + "ON CONFLICT(project, rel_path) DO UPDATE SET " ++ + "sha256=?3, mtime_ns=?4, size=?5;") orelse return ERR; + + _ = bindText(stmt, 1, project); + _ = bindText(stmt, 2, rel_path); + _ = bindText(stmt, 3, sha256); + _ = c.sqlite3_bind_int64(stmt, 4, mtime_ns); + _ = c.sqlite3_bind_int64(stmt, 5, size); + + if (c.sqlite3_step(stmt) != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "upsert_file_hash"); + return ERR; + } + return OK; +} + +pub export fn cbm_store_get_file_hashes(s_c: ?*Store, project: CStr, out: *[*c]c.cbm_file_hash_t, count: *c_int) c_int { + const s = s_c orelse return ERR; + const stmt = prepareCached(s, &s.stmts.get_file_hashes, "SELECT project, rel_path, sha256, mtime_ns, size " ++ + "FROM file_hashes WHERE project = ?1;") orelse return ERR; + + _ = bindText(stmt, 1, project); + + var rows = Rows(c.cbm_file_hash_t).init(ST_INIT_CAP_16); + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + const row = rows.add(); + row.project = columnDup(stmt, 0); + row.rel_path = columnDup(stmt, 1); + row.sha256 = columnDup(stmt, 2); + row.mtime_ns = c.sqlite3_column_int64(stmt, 3); + row.size = c.sqlite3_column_int64(stmt, 4); + } + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + cbm_store_free_file_hashes(rows.items, rows.len); + out.* = null; + count.* = 0; + return ERR; + } + + out.* = rows.items; + count.* = rows.len; + return OK; +} + +pub export fn cbm_store_delete_file_hash(s: ?*Store, project: CStr, rel_path: CStr) c_int { + return execCached(s, "delete_file_hash", "DELETE FROM file_hashes WHERE project = ?1 AND rel_path = ?2;", &.{ project, rel_path }, "delete_file_hash"); +} + +pub export fn cbm_store_delete_file_hashes(s: ?*Store, project: CStr) c_int { + return execCached(s, "delete_file_hashes", "DELETE FROM file_hashes WHERE project = ?1;", &.{project}, "delete_file_hashes"); +} + +// ── FindNodesByFileOverlap ─────────────────────────────────────── + +pub export fn cbm_store_find_nodes_by_file_overlap(s_c: ?*Store, project: CStr, file_path: CStr, start_line: c_int, end_line: c_int, out: *[*c]c.cbm_node_t, count: *c_int) c_int { + if (s_c) |s| { + if (usesRepository(s)) { + return c.cbm_zova_repository_find_nodes_by_file_overlap(s.zova_repository, &s.zova_graph_workspace_id, file_path, start_line, end_line, out, count); + } + } + out.* = null; + count.* = 0; + const s = s_c orelse return ERR; + + var stmt: ?*Stmt = null; + if (prepare(s.db, node_columns ++ + "WHERE project = ?1 AND file_path = ?2 " ++ + "AND label NOT IN ('Module', 'Package', 'File', 'Folder') " ++ + "AND start_line <= ?4 AND end_line >= ?3 " ++ + "ORDER BY start_line", &stmt) != c.SQLITE_OK) + { + storeSetErrorSqlite(s, "overlap prepare"); + return ERR; + } + defer _ = c.sqlite3_finalize(stmt); + + _ = bindText(stmt, 1, project); + _ = bindText(stmt, 2, file_path); + _ = c.sqlite3_bind_int(stmt, 3, start_line); + _ = c.sqlite3_bind_int(stmt, 4, end_line); + + return collectNodes(s, stmt, ST_INIT_CAP_8, out, count); +} + +// ── FindNodesByQNSuffix ────────────────────────────────────────── + +pub export fn cbm_store_find_nodes_by_qn_suffix(s_c: ?*Store, project_c: CStr, suffix: CStr, out: *[*c]c.cbm_node_t, count: *c_int) c_int { + out.* = null; + count.* = 0; + const s = s_c orelse return ERR; + if (s.db == null) return ERR; + var project = project_c; + if (storeLegacyScope(s, &project) != OK) return ERR; + // Match QNs ending with ".suffix" or exactly equal to suffix + var like_pattern: [c.CBM_SZ_512]u8 = undefined; + _ = interop.bprint(&like_pattern, "%.{s}", .{interop.sp(suffix)}); + + const sql_with_project = node_columns ++ + "WHERE project = ?1 AND (qualified_name LIKE ?2 OR qualified_name = ?3)"; + const sql_any = node_columns ++ + "WHERE (qualified_name LIKE ?1 OR qualified_name = ?2)"; + + var stmt: ?*Stmt = null; + if (prepare(s.db, if (project != null) sql_with_project else sql_any, &stmt) != c.SQLITE_OK) { + storeSetErrorSqlite(s, "qn_suffix prepare"); + return ERR; + } + + if (project != null) { + _ = bindText(stmt, 1, project); + _ = bindText(stmt, 2, &like_pattern); + _ = bindText(stmt, 3, suffix); + } else { + _ = bindText(stmt, 1, &like_pattern); + _ = bindText(stmt, 2, suffix); + } + + var nodes: [*c]c.cbm_node_t = null; + var n: c_int = 0; + const rc = collectNodes(s, stmt, ST_INIT_CAP_8, &nodes, &n); + _ = c.sqlite3_finalize(stmt); + if (rc != OK) return rc; + if (usesRepository(s)) { + // The view's ids are Zova node keys; callers expect repository ids. + for (span(nodes, n)) |*node| { + var resolved = std.mem.zeroes(c.cbm_node_t); + if (c.cbm_zova_repository_find_node_by_qn(s.zova_repository, &s.zova_graph_workspace_id, node.qualified_name, &resolved) != OK) { + cbm_store_free_nodes(nodes, n); + return ERR; + } + cbm_node_free_fields(node); + node.* = resolved; + } + } + out.* = nodes; + count.* = n; + return OK; +} + +// ── NodeDegree ─────────────────────────────────────────────────── + +/// COUNT(*) of the CALLS edges that have node_id in `column`; leaves out +/// untouched when the query fails. +fn countCallEdges(s: *Store, comptime column: []const u8, node_id: i64, out: *c_int) void { + var stmt: ?*Stmt = null; + if (prepare(s.db, "SELECT COUNT(*) FROM edges WHERE " ++ column ++ " = ?1 AND type = 'CALLS'", &stmt) != c.SQLITE_OK) return; + _ = c.sqlite3_bind_int64(stmt, 1, node_id); + if (c.sqlite3_step(stmt) == c.SQLITE_ROW) out.* = c.sqlite3_column_int(stmt, 0); + _ = c.sqlite3_finalize(stmt); +} + +pub export fn cbm_store_node_degree(s_c: ?*Store, node_id: i64, in_deg: [*c]c_int, out_deg: [*c]c_int) void { + const s = s_c orelse { + if (in_deg != null) in_deg.* = 0; + if (out_deg != null) out_deg.* = 0; + return; + }; + in_deg.* = 0; + out_deg.* = 0; + + if (s.backend) |backend| { + if (backend.node_degree.?(s.backend_ctx, node_id, in_deg, out_deg) != OK) { + in_deg.* = 0; + out_deg.* = 0; + } + return; + } + if (s.zova_workspace_query) { + var stable = std.mem.zeroes([c.CBM_ZOVA_WORKSPACE_ID_MAX + 96]u8); + var graph: [c.CBM_ZOVA_WORKSPACE_ID_MAX + 32]u8 = undefined; + const workspace_id: CStr = &s.zova_graph_workspace_id; + if (s.zova_graph_session == null or s.zova_repository == null or + c.cbm_zova_repository_stable_id_for_numeric_id(s.zova_repository, workspace_id, node_id, &stable, stable.len) != OK) + { + return; + } + if (stable[0] == 0 or c.cbm_zova_workspace_graph_name(workspace_id, &graph, graph.len) != 0) return; + const types = [_]CStr{"CALLS"}; + if (c.cbm_zova_graph_session_degree(s.zova_graph_session, workspace_id, &graph, &stable, "inbound", &types, 1, in_deg) != 0 or + c.cbm_zova_graph_session_degree(s.zova_graph_session, workspace_id, &graph, &stable, "outbound", &types, 1, out_deg) != 0) + { + in_deg.* = 0; + out_deg.* = 0; + return; + } + s.zova_native_topology_ops += 2; + return; + } + + countCallEdges(s, "target_id", node_id, in_deg); + countCallEdges(s, "source_id", node_id, out_deg); +} + +// ── List distinct file paths ───────────────────────────────────── + +fn freeStrings(items: [*c][*c]u8, count: c_int) void { + for (span(items, count)) |item| c.free(item); + c.free(@ptrCast(items)); +} + +pub export fn cbm_store_list_files(s_c: ?*Store, project_c: CStr, out: *[*c][*c]u8, count: *c_int) c_int { + out.* = null; + count.* = 0; + const s = s_c orelse return ERR; + if (s.db == null or project_c == null) return ERR; + var project = project_c; + if (storeLegacyScope(s, &project) != OK) return ERR; + + // Upstream cc9c2f4c: a node path can name a graph-only identity (a Folder, + // , another synthetic source). Indexed stores have one + // canonical File node per scannable path, so prefer that exact set; stores + // without File nodes keep the node-path fallback minus Folder directories. + var stmt: ?*Stmt = null; + if (prepare(s.db, "SELECT DISTINCT file_path FROM nodes " ++ + "WHERE project = ?1 AND label != 'Folder' " ++ + "AND file_path IS NOT NULL AND file_path != '' " ++ + "AND (label = 'File' OR NOT EXISTS (" ++ + "SELECT 1 FROM nodes WHERE project = ?1 AND label = 'File'))", &stmt) != c.SQLITE_OK) + { + return ERR; + } + _ = c.sqlite3_bind_text(stmt, 1, project, -1, null); + + var files = Rows([*c]u8).init(c.CBM_SZ_64); + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + const fp = columnText(stmt, 0); + if (fp == null) continue; + files.add().* = interop.heapStrdup(fp); + } + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(stmt); + freeStrings(files.items, files.len); + return ERR; + } + _ = c.sqlite3_finalize(stmt); + out.* = files.items; + count.* = files.len; + return OK; +} + +// ── Node neighbor names ────────────────────────────────────────── + +fn queryNeighborNames(db: ?*c.sqlite3, sql: CStr, node_id: i64, limit: c_int, out: *[*c][*c]u8, out_count: *c_int) c_int { + out.* = null; + out_count.* = 0; + var stmt: ?*Stmt = null; + if (prepare(db, sql, &stmt) != c.SQLITE_OK) return c.CBM_NOT_FOUND; + _ = c.sqlite3_bind_int64(stmt, 1, node_id); + _ = c.sqlite3_bind_int(stmt, 2, limit); + + var names = Rows([*c]u8).init(ST_INIT_CAP_8); + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + const name = columnText(stmt, 0); + if (name == null) continue; + names.add().* = c.strdup(name); + } + _ = c.sqlite3_finalize(stmt); + if (rc != c.SQLITE_DONE) { + // No store handle here; neighbor names are include_connected + // enrichment — fail the call, primary surfaces report loudly. + freeStrings(names.items, names.len); + return c.CBM_NOT_FOUND; + } + out.* = names.items; + out_count.* = names.len; + return 0; +} + +fn storeNameCompare(left: ?*const anyopaque, right: ?*const anyopaque) callconv(.c) c_int { + return c.strcmp(@as(*const [*c]u8, @ptrCast(@alignCast(left))).*, @as(*const [*c]u8, @ptrCast(@alignCast(right))).*); +} + +/// Distinct, sorted names of the call neighbors of a Zova node, from the graph's +/// own adjacency index and the workspace nodes view. +fn storeZovaNeighborNames(s: *Store, stable_id: CStr, inbound: bool, limit: c_int, out: *[*c][*c]u8, out_count: *c_int) c_int { + var keys: [*c]i64 = null; + var key_count: c_int = 0; + if (c.cbm_zova_repository_call_neighbor_keys(s.zova_repository, &s.zova_graph_workspace_id, stable_id, inbound, &keys, &key_count) != OK) { + return ERR; + } + var stmt: ?*Stmt = null; + if (key_count == 0 or prepare(s.db, "SELECT name FROM nodes WHERE id = ?1", &stmt) != c.SQLITE_OK) { + c.free(keys); + return if (key_count == 0) OK else ERR; + } + const names: [*c][*c]u8 = interop.mallocN([*c]u8, @intCast(key_count)); + var count: usize = 0; + if (names != null) { + for (span(keys, key_count)) |key| { + _ = c.sqlite3_reset(stmt); + _ = c.sqlite3_bind_int64(stmt, 1, key); + const name: CStr = if (c.sqlite3_step(stmt) == c.SQLITE_ROW) columnText(stmt, 0) else null; + if (name != null) { + names[count] = interop.heapStrdup(name); + count += 1; + } + } + } + _ = c.sqlite3_finalize(stmt); + c.free(keys); + if (names == null) return ERR; + c.qsort(@ptrCast(names), count, @sizeOf([*c]u8), storeNameCompare); + var kept: usize = 0; + for (names[0..count]) |name| { + if (kept < limit and (kept == 0 or !streq(names[kept - 1], name))) { + names[kept] = name; + kept += 1; + } else { + c.free(name); + } + } + out.* = names; + out_count.* = @intCast(kept); + return OK; +} + +pub export fn cbm_store_node_neighbor_names(s_c: ?*Store, node_id: i64, limit: c_int, out_callers: *[*c][*c]u8, caller_count: *c_int, out_callees: *[*c][*c]u8, callee_count: *c_int) c_int { + const s = s_c orelse return c.CBM_NOT_FOUND; + out_callers.* = null; + caller_count.* = 0; + out_callees.* = null; + callee_count.* = 0; + + if (usesRepository(s)) { + var project: CStr = null; + var stable = std.mem.zeroes([c.CBM_ZOVA_WORKSPACE_ID_MAX + 96]u8); + if (storeLegacyScope(s, &project) != OK or + c.cbm_zova_repository_stable_id_for_numeric_id(s.zova_repository, &s.zova_graph_workspace_id, node_id, &stable, stable.len) != OK) + { + return ERR; + } + if (storeZovaNeighborNames(s, &stable, true, limit, out_callers, caller_count) != OK or + storeZovaNeighborNames(s, &stable, false, limit, out_callees, callee_count) != OK) + { + return ERR; + } + return 0; + } + + _ = queryNeighborNames(s.db, "SELECT DISTINCT n.name FROM edges e JOIN nodes n ON e.source_id = n.id " ++ + "WHERE e.target_id = ?1 AND e.type IN ('CALLS','HTTP_CALLS','ASYNC_CALLS') " ++ + "ORDER BY n.name LIMIT ?2", node_id, limit, out_callers, caller_count); + + _ = queryNeighborNames(s.db, "SELECT DISTINCT n.name FROM edges e JOIN nodes n ON e.target_id = n.id " ++ + "WHERE e.source_id = ?1 AND e.type IN ('CALLS','HTTP_CALLS','ASYNC_CALLS') " ++ + "ORDER BY n.name LIMIT ?2", node_id, limit, out_callees, callee_count); + + return 0; +} + +fn countDegreesDirection(s: *Store, node_ids: []const i64, in_clause: []const u8, edge_type: ?CStr, inbound: bool, out_counts: []c_int) c_int { + var sql: [ST_SQL_BUF]u8 = undefined; + const id_col = if (inbound) "target_id" else "source_id"; + _ = interop.bprint(&sql, "SELECT {s}, COUNT(*) FROM edges WHERE {s} IN ({s}){s} GROUP BY {s}", .{ + id_col, id_col, in_clause, if (edge_type != null) " AND type = ?" else "", id_col, + }); + + var stmt: ?*Stmt = null; + if (prepare(s.db, &sql, &stmt) != c.SQLITE_OK) return ERR; + + for (node_ids, 1..) |id, col| _ = c.sqlite3_bind_int64(stmt, @intCast(col), id); + if (edge_type) |t| _ = bindText(stmt, @intCast(node_ids.len + 1), t); + + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + const nid = c.sqlite3_column_int64(stmt, 0); + if (std.mem.findScalar(i64, node_ids, nid)) |i| out_counts[i] = c.sqlite3_column_int(stmt, 1); + } + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(stmt); + return ERR; + } + _ = c.sqlite3_finalize(stmt); + return OK; +} + +pub export fn cbm_store_batch_count_degrees(s_c: ?*Store, node_ids_c: [*c]const i64, id_count: c_int, edge_type: CStr, out_in_c: [*c]c_int, out_out_c: [*c]c_int) c_int { + const s = s_c orelse return ERR; + if (node_ids_c == null or id_count <= 0 or out_in_c == null or out_out_c == null) return ERR; + const node_ids = span(node_ids_c, id_count); + const out_in = span(out_in_c, id_count); + const out_out = span(out_out_c, id_count); + + @memset(out_in, 0); + @memset(out_out, 0); + + if (s.zova_workspace_query) { + for (node_ids, out_in, out_out) |node_id, *in_deg, *out_deg| { + if (edge_type == null or streq(edge_type, "CALLS")) { + cbm_store_node_degree(s, node_id, in_deg, out_deg); + continue; + } + var edges: [*c]c.cbm_edge_t = null; + var count: c_int = 0; + if (storeZovaWorkspaceEdges(s, node_id, "inbound", edge_type, &edges, &count) != OK) return ERR; + in_deg.* = count; + cbm_store_free_edges(edges, count); + edges = null; + count = 0; + if (storeZovaWorkspaceEdges(s, node_id, "outbound", edge_type, &edges, &count) != OK) return ERR; + out_deg.* = count; + cbm_store_free_edges(edges, count); + } + return OK; + } + + // Build IN clause: (?,?,?) + var in_clause: [c.CBM_SZ_4K]u8 = undefined; + var pos: usize = 0; + for (0..node_ids.len) |i| { + if (pos >= in_clause.len - ST_IN_CLAUSE_MARGIN) break; + if (i > 0) { + in_clause[pos] = ','; + pos += 1; + } + in_clause[pos] = '?'; + pos += 1; + } + + const typed: ?CStr = if (edge_type != null and edge_type[0] != 0) edge_type else null; + + const rc = countDegreesDirection(s, node_ids, in_clause[0..pos], typed, true, out_in); + if (rc != OK) return rc; + + return countDegreesDirection(s, node_ids, in_clause[0..pos], typed, false, out_out); +} + +// ── UpsertFileHashBatch ────────────────────────────────────────── + +pub export fn cbm_store_upsert_file_hash_batch(s: ?*Store, hashes: [*c]const c.cbm_file_hash_t, count: c_int) c_int { + if (count == 0) return OK; + + var rc = cbm_store_begin(s); + if (rc != OK) return rc; + + for (span(hashes, count)) |hash| { + rc = cbm_store_upsert_file_hash(s, hash.project, hash.rel_path, hash.sha256, hash.mtime_ns, hash.size); + if (rc != OK) { + _ = cbm_store_rollback(s); + return rc; + } + } + + return cbm_store_commit(s); +} + +// ── FindEdgesByURLPath ─────────────────────────────────────────── + +pub export fn cbm_store_find_edges_by_url_path(s_c: ?*Store, project: CStr, keyword: CStr, out: *[*c]c.cbm_edge_t, count: *c_int) c_int { + out.* = null; + count.* = 0; + const s = s_c orelse return ERR; + + // Search properties JSON for url_path containing keyword + var like_pattern: [c.CBM_SZ_512]u8 = undefined; + _ = interop.bprint(&like_pattern, "%\"url_path\":\"%{s}%\"%", .{interop.sp(keyword)}); + + var stmt: ?*Stmt = null; + if (prepare(s.db, edge_columns ++ "WHERE project = ?1 AND properties LIKE ?2", &stmt) != c.SQLITE_OK) { + storeSetErrorSqlite(s, "url_path prepare"); + return ERR; + } + defer _ = c.sqlite3_finalize(stmt); + + _ = bindText(stmt, 1, project); + _ = bindText(stmt, 2, &like_pattern); + + return collectEdges(s, stmt, ST_INIT_CAP_8, out, count); +} + +// ── RestoreFrom ────────────────────────────────────────────────── + +pub export fn cbm_store_restore_from(dst_c: ?*Store, src_c: ?*Store) c_int { + const dst = dst_c orelse return ERR; + const src = src_c orelse return ERR; + const bk = c.sqlite3_backup_init(dst.db, "main", src.db, "main") orelse { + storeSetErrorSqlite(dst, "backup init"); + return ERR; + }; + const rc = c.sqlite3_backup_step(bk, -1); // copy all pages + _ = c.sqlite3_backup_finish(bk); + + if (rc != c.SQLITE_DONE) { + storeSetError(dst, "backup step failed"); + return ERR; + } + return OK; +} + +// ── Search ─────────────────────────────────────────────────────── + +/// Convert a glob pattern to SQL LIKE pattern. +pub export fn cbm_glob_to_like(pattern_c: ?Str) [*c]u8 { + const pattern = std.mem.span(pattern_c orelse return null); + const out: [*]u8 = @as(?[*]u8, interop.mallocN(u8, pattern.len * ST_GROWTH + 1)) orelse return null; + var j: usize = 0; + + var i: usize = 0; + while (i < pattern.len) : (i += 1) { + if (pattern[i] == '*' and i + 1 < pattern.len and pattern[i + 1] == '*') { + // Remove leading / from output if present (handles glob dir-star) + if (j > 0 and out[j - 1] == '/') j -= 1; + out[j] = '%'; + j += 1; + i += 1; // skip second * + if (i + 1 < pattern.len and pattern[i + 1] == '/') i += 1; // skip trailing / + } else { + out[j] = switch (pattern[i]) { + '*' => '%', + '?' => '_', + else => |ch| ch, + }; + j += 1; + } + } + out[j] = 0; + return out; +} + +// ── extractLikeHints ───────────────────────────────────────────── + +pub export fn cbm_extract_like_hints(pattern_c: ?Str, out_c: ?[*][*c]u8, max_out: c_int) c_int { + const pattern = std.mem.span(pattern_c orelse return 0); + const out = out_c orelse return 0; + if (max_out <= 0) return 0; + + // Bail on alternation — can't convert OR regex to AND LIKE + if (std.mem.findScalar(u8, pattern, '|') != null) return 0; + + var count: c_int = 0; + var buf: [c.CBM_SZ_256]u8 = undefined; + var blen: usize = 0; + + var i: usize = 0; + while (i < pattern.len) { + const ch = pattern[i]; + switch (ch) { + '\\' => { + // Escaped char — the next char is literal + if (i + 1 < pattern.len) { + if (blen < buf.len - 1) { + buf[blen] = pattern[i + 1]; + blen += 1; + } + i += 2; + } else { + i += 1; + } + }, + '.', '*', '+', '?', '^', '$', '(', ')', '[', ']', '{', '}' => { + // Meta character — flush current literal segment + if (blen >= ST_GLOB_MIN_LEN and count < max_out) { + buf[blen] = 0; + out[@intCast(count)] = c.strdup(&buf); + count += 1; + } + blen = 0; + i += 1; + }, + else => { + if (blen < buf.len - 1) { + buf[blen] = ch; + blen += 1; + } + i += 1; + }, + } + } + // Flush trailing segment + if (blen >= ST_GLOB_MIN_LEN and count < max_out) { + buf[blen] = 0; + out[@intCast(count)] = c.strdup(&buf); + count += 1; + } + return count; +} + +// ── ensureCaseInsensitive / stripCaseFlag ──────────────────────── + +const case_flag = "(?i)"; + +fn hasCaseFlag(pattern: Str) bool { + return c.strncmp(pattern, case_flag, case_flag.len) == 0; +} + +pub export fn cbm_ensure_case_insensitive(pattern_c: ?Str) Str { + const static = struct { + var buf = std.mem.zeroes([c.CBM_SZ_2K:0]u8); + }; + const buf: []u8 = &static.buf; + const pattern = pattern_c orelse { + buf[0] = 0; + return &static.buf; + }; + // Already has (?i) prefix? Return as-is. + _ = interop.bprint(buf, "{s}{s}", .{ if (hasCaseFlag(pattern)) "" else case_flag, std.mem.span(pattern) }); + return &static.buf; +} + +pub export fn cbm_strip_case_flag(pattern_c: ?Str) Str { + const static = struct { + var buf = std.mem.zeroes([c.CBM_SZ_2K:0]u8); + }; + const buf: []u8 = &static.buf; + const pattern = pattern_c orelse { + buf[0] = 0; + return &static.buf; + }; + _ = interop.bprint(buf, "{s}", .{std.mem.span(if (hasCaseFlag(pattern)) pattern + case_flag.len else pattern)}); + return &static.buf; +} + +/// Wrap a literal string in % for use as a LIKE pattern (%literal%). +fn makeLikeHint(literal: CStr) [*c]u8 { + const text = std.mem.span(literal); + const buf: [*c]u8 = interop.mallocN(u8, text.len + 3); // % + literal + % + NUL + if (buf != null) { + buf[0] = '%'; + @memcpy(buf[1..][0..text.len], text); + buf[text.len + 1] = '%'; + buf[text.len + 2] = 0; + } + return buf; +} + +/// The WHERE clause of a search while it is built: the clause text, the text +/// values to bind in placeholder order, and the malloc'd LIKE strings that +/// must outlive statement execution. +const SearchWhere = struct { + text: [c.CBM_SZ_2K]u8 = undefined, + len: usize = 0, + nparams: c_int = 0, + binds: [ST_SEARCH_MAX_BINDS]CStr = undefined, + bind_count: usize = 0, + /// Pool of malloc'd strings, freed in one shot after both statements are + /// finalized. + pool: [ST_LIKE_POOL_MAX][*c]u8 = undefined, + pool_count: usize = 0, + + /// Takes ownership of ptr; false when the pool is full and ptr was freed. + fn poolAdd(w: *SearchWhere, ptr: [*c]u8) bool { + if (ptr != null and w.pool_count < ST_LIKE_POOL_MAX) { + w.pool[w.pool_count] = ptr; + w.pool_count += 1; + return true; + } + c.free(ptr); // pool full — don't leak + return false; + } + + fn poolFree(w: *SearchWhere) void { + for (w.pool[0..w.pool_count]) |ptr| c.free(ptr); + w.pool_count = 0; + } + + /// Append a WHERE clause fragment, joining with AND if not the first. + fn append(w: *SearchWhere, comptime fmt: []const u8, args: anytype) void { + if (w.nparams > 0) w.len = appendf(&w.text, w.len, " AND ", .{}); + w.len = appendf(&w.text, w.len, fmt, args); + w.nparams += 1; + } + + /// The placeholder number the next bound value gets. + fn nextParam(w: *const SearchWhere) usize { + return w.bind_count + 1; + } + + /// Bind a text parameter and increment the bind index. + fn bind(w: *SearchWhere, val: CStr) void { + w.binds[w.bind_count] = val; + w.bind_count += 1; + } + + fn bindAll(w: *const SearchWhere, stmt: ?*Stmt) void { + for (w.binds[0..w.bind_count], 1..) |val, col| _ = bindText(stmt, @intCast(col), val); + } + + /// Append a regex WHERE clause for a column (case-sensitive or insensitive). + fn addRegex(w: *SearchWhere, comptime column: []const u8, pattern: CStr, case_sensitive: bool) void { + if (case_sensitive) { + w.append(column ++ " REGEXP ?{d}", .{w.nextParam()}); + } else { + w.append("iregexp(?{d}, " ++ column ++ ")", .{w.nextParam()}); + } + w.bind(pattern); + } + + /// Prepend LIKE pre-filter conditions for literal segments of a regex pattern. + /// The idx_nodes_name index satisfies LIKE '%literal%', cutting the rows that + /// reach the (more expensive) iregexp call to only those containing the literal. + /// cbm_extract_like_hints bails on alternation, so no false negatives. + fn addLikeHints(w: *SearchWhere, comptime column: []const u8, pattern: CStr) void { + var hints: [ST_LIKE_HINT_MAX][*c]u8 = undefined; + const nhints = cbm_extract_like_hints(pattern, &hints, ST_LIKE_HINT_MAX); + for (hints[0..@intCast(nhints)]) |hint| { + const lp = makeLikeHint(hint); + c.free(hint); + if (lp == null) continue; + if (!w.poolAdd(lp)) continue; // lp was freed — skip bind + w.append(column ++ " LIKE ?{d}", .{w.nextParam()}); + w.bind(lp); + } + } + + /// Build basic WHERE clauses: project, label, name, file, qn patterns. + fn addBasic(w: *SearchWhere, params: *const c.cbm_search_params_t) void { + if (params.project != null) { + w.append("n.project = ?{d}", .{w.nextParam()}); + w.bind(params.project); + } + // Ignore an empty-string label: it is non-NULL but should behave like an + // omitted label (no filter), matching the BM25 query path. Without the + // label[0] guard, name_pattern/qn_pattern searches that pass label="" + // append `n.label = ''`, which matches no node and silently returns + // zero results (issue #481). + if (params.label != null and params.label[0] != 0) { + w.append("n.label = ?{d}", .{w.nextParam()}); + w.bind(params.label); + } + if (params.name_pattern != null) { + w.addLikeHints("n.name", params.name_pattern); + w.addRegex("n.name", params.name_pattern, params.case_sensitive); + } + if (params.qn_pattern != null) { + w.addLikeHints("n.qualified_name", params.qn_pattern); + w.addRegex("n.qualified_name", params.qn_pattern, params.case_sensitive); + } + if (params.file_pattern != null) { + var lp = cbm_glob_to_like(params.file_pattern); + // A file_pattern with no glob wildcards is treated as a path-substring + // match (issue #200): file_pattern="offer-server" should match + // "src/offer-server/x.js", not only a path equal to "offer-server". + // Patterns that DO contain * / ? keep their explicit glob semantics. + if (lp != null and c.strchr(params.file_pattern, '*') == null and c.strchr(params.file_pattern, '?') == null) { + const contains = makeLikeHint(lp); + if (contains != null) { + c.free(lp); + lp = contains; + } + } + if (w.poolAdd(lp)) { + w.append("n.file_path LIKE ?{d}", .{w.nextParam()}); + w.bind(lp); + } + } + } + + /// Build advanced WHERE clauses: relationship, entry points, exclude labels. + fn addAdvanced(w: *SearchWhere, params: *const c.cbm_search_params_t) void { + if (params.relationship != null) { + w.append("EXISTS(SELECT 1 FROM edges e WHERE " ++ + "(e.source_id = n.id OR e.target_id = n.id) AND e.type = ?{d})", .{w.nextParam()}); + w.bind(params.relationship); + } + if (params.exclude_entry_points) { + // Exclude nodes with no inbound CALLS but at least one outbound CALLS. + // Dead code (degree=0) is NOT excluded — only true entry points. + w.append("NOT (NOT EXISTS(SELECT 1 FROM edges e WHERE e.target_id = n.id " ++ + "AND e.type = 'CALLS') " ++ + "AND EXISTS(SELECT 1 FROM edges e2 WHERE e2.source_id = n.id " ++ + "AND e2.type = 'CALLS'))", .{}); + } + if (params.exclude_labels != null) { + // Build exclude-labels NOT IN clause with bind placeholders. + var clause: [c.CBM_SZ_512]u8 = undefined; + var elen = appendf(&clause, 0, "n.label NOT IN (", .{}); + var i: usize = 0; + while (params.exclude_labels[i] != null and w.bind_count < w.binds.len) : (i += 1) { + if (i > 0) elen = appendf(&clause, elen, ",", .{}); + elen = appendf(&clause, elen, "?{d}", .{w.nextParam()}); + w.bind(params.exclude_labels[i]); + } + _ = appendf(&clause, elen, ")", .{}); + w.append("{s}", .{std.mem.sliceTo(&clause, 0)}); + } + } +}; + +fn searchApplyDegreeFilter(sql: []u8, p: *const c.cbm_search_params_t) void { + if (p.min_degree < 0 and p.max_degree < 0) return; + var inner_sql: [c.CBM_SZ_4K]u8 = undefined; + _ = interop.bprint(&inner_sql, "{s}", .{std.mem.sliceTo(sql, 0)}); + const inner = std.mem.sliceTo(&inner_sql, 0); + if (p.min_degree >= 0 and p.max_degree >= 0) { + _ = interop.bprint(sql, "SELECT * FROM ({s}) WHERE (in_deg + out_deg) >= {d} AND (in_deg + out_deg) <= {d}", .{ inner, p.min_degree, p.max_degree }); + } else if (p.min_degree >= 0) { + _ = interop.bprint(sql, "SELECT * FROM ({s}) WHERE (in_deg + out_deg) >= {d}", .{ inner, p.min_degree }); + } else { + _ = interop.bprint(sql, "SELECT * FROM ({s}) WHERE (in_deg + out_deg) <= {d}", .{ inner, p.max_degree }); + } +} + +pub export fn cbm_store_search(s_c: ?*Store, params: *const c.cbm_search_params_t, out: *c.cbm_search_output_t) c_int { + out.* = std.mem.zeroes(c.cbm_search_output_t); + const s = s_c orelse return ERR; + if (s.backend) |backend| return backend.search.?(s.backend_ctx, params, out); + if (s.db == null) return ERR; + if (usesRepository(s)) { + const rc = c.cbm_zova_repository_search(s.zova_repository, &s.zova_graph_workspace_id, params, out); + if (rc == OK) s.zova_native_topology_ops += 8; + return rc; + } + + var sql: [c.CBM_SZ_4K]u8 = undefined; + var count_sql: [c.CBM_SZ_4K]u8 = undefined; + + const select_cols = "SELECT n.id, n.project, n.label, n.name, n.qualified_name, " ++ + "n.file_path, n.start_line, n.end_line, n.properties, " ++ + "(SELECT COUNT(*) FROM edges e WHERE e.target_id = n.id AND " ++ + "e.type IN ('CALLS', 'USAGE', 'CALL_REFERENCE', 'INHERITS', 'IMPLEMENTS')) " ++ + "AS in_deg, " ++ + "(SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id AND " ++ + "e.type IN ('CALLS', 'USAGE', 'CALL_REFERENCE', 'INHERITS', 'IMPLEMENTS')) " ++ + "AS out_deg "; + + var where: SearchWhere = .{}; + where.text[0] = 0; + where.addBasic(params); + where.addAdvanced(params); + defer where.poolFree(); + const where_text = where.text[0..where.len]; + + // Build full SQL + if (where.nparams > 0) { + _ = interop.bprint(&sql, select_cols ++ " FROM nodes n WHERE {s}", .{where_text}); + } else { + _ = interop.bprint(&sql, select_cols ++ " FROM nodes n", .{}); + } + + // Degree filters + const has_degree_filter = params.min_degree >= 0 or params.max_degree >= 0; + searchApplyDegreeFilter(&sql, params); + + // Count query — stripped of per-row edge subqueries for the common (no-degree-filter) + // case, since we only need the row count, not in_deg/out_deg. The degree-filter + // case must wrap the full query because the filter references those columns. + if (has_degree_filter) { + _ = interop.bprint(&count_sql, "SELECT COUNT(*) FROM ({s})", .{std.mem.sliceTo(&sql, 0)}); + } else if (where.nparams > 0) { + _ = interop.bprint(&count_sql, "SELECT COUNT(*) FROM nodes n WHERE {s}", .{where_text}); + } else { + _ = interop.bprint(&count_sql, "SELECT COUNT(*) FROM nodes n", .{}); + } + + // Add ORDER BY + LIMIT + const limit = if (params.limit > 0) params.limit else c.CBM_DEFAULT_SEARCH_LIMIT; + const order_by: []const u8 = if (has_degree_filter) "name, qualified_name, id" else "n.name, n.qualified_name, n.id"; + _ = appendf(&sql, std.mem.sliceTo(&sql, 0).len, " ORDER BY {s} LIMIT {d} OFFSET {d}", .{ order_by, limit, params.offset }); + + // Execute count query + var cnt_stmt: ?*Stmt = null; + if (prepare(s.db, &count_sql, &cnt_stmt) == c.SQLITE_OK) { + where.bindAll(cnt_stmt); + if (c.sqlite3_step(cnt_stmt) == c.SQLITE_ROW) out.total = c.sqlite3_column_int(cnt_stmt, 0); + _ = c.sqlite3_finalize(cnt_stmt); + } + + // Execute main query + var main_stmt: ?*Stmt = null; + if (prepare(s.db, &sql, &main_stmt) != c.SQLITE_OK) { + storeSetErrorSqlite(s, "search prepare"); + return ERR; + } + + where.bindAll(main_stmt); + + var results = Rows(c.cbm_search_result_t).init(ST_INIT_CAP_16); + var rc = c.sqlite3_step(main_stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(main_stmt)) { + const result = results.add(); + result.* = std.mem.zeroes(c.cbm_search_result_t); + scanNode(main_stmt, &result.node); + result.in_degree = c.sqlite3_column_int(main_stmt, 9); + result.out_degree = c.sqlite3_column_int(main_stmt, 10); + } + out.results = results.items; + out.count = results.len; + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(main_stmt); + return ERR; + } + + _ = c.sqlite3_finalize(main_stmt); + return OK; +} + +pub export fn cbm_store_search_free(out_c: ?*c.cbm_search_output_t) void { + const out = out_c orelse return; + for (span(out.results, out.count)) |*r| { + cbm_node_free_fields(&r.node); + for (span(r.connected_names, r.connected_count)) |*name| interop.strFree(name); + c.free(@ptrCast(r.connected_names)); + } + c.free(out.results); + out.* = std.mem.zeroes(c.cbm_search_output_t); +} + +// ── BFS Traversal ──────────────────────────────────────────────── + +const TraverseResult = c.cbm_traverse_result_t; +const NodeHop = c.cbm_node_hop_t; + +/// Binds the edge types of a `?1,?2,…` clause; "CALLS" when none are given. +fn bfsBindEdgeTypes(stmt: ?*Stmt, edge_types: []const CStr) void { + if (edge_types.len > 0) { + for (edge_types, 1..) |edge_type, col| _ = bindText(stmt, @intCast(col), edge_type); + } else { + _ = bindText(stmt, 1, "CALLS"); + } +} + +fn bfsCollectEdges( + s: *Store, + start_id: i64, + visited: []const NodeHop, + types_clause: []const u8, + edge_types: []const CStr, + out_edges: *[*c]c.cbm_edge_info_t, + out_edge_count: *c_int, +) c_int { + // Build ID set: root + all visited + var id_set: [c.CBM_SZ_4K]u8 = undefined; + var ilen = appendf(&id_set, 0, "{d}", .{start_id}); + for (visited) |*hop| ilen = appendf(&id_set, ilen, ",{d}", .{hop.node.id}); + const ids = id_set[0..ilen]; + + var edge_sql: [ST_SQL_BUF]u8 = undefined; + _ = interop.bprint(&edge_sql, "SELECT n1.name, n2.name, e.type, e.source_id, e.target_id, e.properties " ++ + "FROM edges e " ++ + "JOIN nodes n1 ON n1.id = e.source_id " ++ + "JOIN nodes n2 ON n2.id = e.target_id " ++ + "WHERE e.source_id IN ({s}) AND e.target_id IN ({s}) " ++ + "AND e.type IN ({s})", .{ ids, ids, types_clause }); + + var estmt: ?*Stmt = null; + if (prepare(s.db, &edge_sql, &estmt) != c.SQLITE_OK) { + out_edges.* = null; + out_edge_count.* = 0; + return OK; + } + + bfsBindEdgeTypes(estmt, edge_types); + + var edges = Rows(c.cbm_edge_info_t).init(ST_INIT_CAP_8); + var rc = c.sqlite3_step(estmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(estmt)) { + edges.add().* = .{ + .from_name = columnDup(estmt, 0), + .to_name = columnDup(estmt, 1), + .type = columnDup(estmt, 2), + .confidence = 1.0, + .source_id = c.sqlite3_column_int64(estmt, 3), + .target_id = c.sqlite3_column_int64(estmt, 4), + .properties_json = columnDup(estmt, 5), + }; + } + out_edges.* = edges.items; + out_edge_count.* = edges.len; + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(estmt); + return ERR; + } + _ = c.sqlite3_finalize(estmt); + return OK; +} + +/// Build parameterized placeholder list "?1,?2,?3" for N edge types. +fn bfsBuildTypesClause(edge_type_count: c_int, buf: []u8) []const u8 { + if (edge_type_count <= 0) return buf[0..appendf(buf, 0, "?1", .{})]; + var tlen: usize = 0; + var i: c_int = 0; + while (i < edge_type_count) : (i += 1) { + if (i > 0) tlen = appendf(buf, tlen, ",", .{}); + tlen = appendf(buf, tlen, "?{d}", .{i + 1}); + } + return buf[0..tlen]; +} + +fn storeFindCachedZovaWorkspaceNode(s_c: ?*Store, node_numeric_id: i64, out: *c.cbm_node_t) c_int { + const s = s_c orelse return NOT_FOUND; + if (!s.zova_workspace_query or + !s.zova_graph_cached_stable_id_valid or + s.zova_graph_cached_numeric_id != node_numeric_id) + { + return NOT_FOUND; + } + return c.cbm_zova_repository_find_node_by_numeric_id(s.zova_repository, &s.zova_graph_workspace_id, node_numeric_id, out); +} + +fn storeZovaWorkspaceBfs( + s: *Store, + start_id: i64, + direction: CStr, + edge_types: []const CStr, + max_depth: c_int, + max_results: c_int, + out: *TraverseResult, +) c_int { + if (!s.zova_workspace_query or s.zova_graph_session == null or + edge_types.len > 1 or + (edge_types.len == 1 and !streq(edge_types[0], "CALLS")) or + (direction != null and !streq(direction, "outbound") and !streq(direction, "inbound"))) + { + return NOT_FOUND; + } + var stable_copy: [c.CBM_ZOVA_WORKSPACE_ID_MAX + 96]u8 = undefined; + stable_copy[0] = 0; + if (s.zova_graph_cached_stable_id_valid and s.zova_graph_cached_numeric_id == start_id) { + _ = interop.bprint(&stable_copy, "{s}", .{std.mem.sliceTo(&s.zova_graph_cached_stable_id, 0)}); + } else { + const stable_rc = c.cbm_zova_repository_stable_id_for_numeric_id(s.zova_repository, &s.zova_graph_workspace_id, start_id, &stable_copy, stable_copy.len); + if (stable_rc != OK) return stable_rc; + if (stable_copy[0] != 0) { + s.zova_graph_cached_numeric_id = start_id; + _ = interop.bprint(&s.zova_graph_cached_stable_id, "{s}", .{std.mem.sliceTo(&stable_copy, 0)}); + s.zova_graph_cached_stable_id_valid = true; + } + } + if (stable_copy[0] == 0) return NOT_FOUND; + var graph_name: [c.CBM_ZOVA_WORKSPACE_ID_MAX + 32]u8 = undefined; + if (c.cbm_zova_workspace_graph_name(&s.zova_graph_workspace_id, &graph_name, graph_name.len) != 0) return ERR; + var visits: [*c]c.cbm_zova_graph_visit_t = null; + var count: c_int = 0; + var metrics = std.mem.zeroes(c.cbm_zova_graph_metrics_t); + if (c.cbm_zova_graph_session_walk_calls( + s.zova_graph_session, + &s.zova_graph_workspace_id, + &graph_name, + &stable_copy, + if (direction != null) direction else "outbound", + max_depth, + max_results, + &visits, + &count, + &metrics, + ) != 0) return ERR; + var items: [*c]NodeHop = null; + if (count != 0) { + items = interop.callocN(NodeHop, @intCast(count)); + if (items == null) { + c.cbm_zova_graph_visits_free(visits, count); + return ERR; + } + } + for (span(items, count), span(visits, count)) |*item, *visit| { + item.node.id = storeStableNumericId(visit.node_id); + item.node.project = interop.heapStrdup(safeStr(out.root.project)); + item.node.label = visit.label; + visit.label = null; + item.node.name = visit.name; + visit.name = null; + item.node.qualified_name = visit.qualified_name; + visit.qualified_name = null; + item.node.file_path = visit.file_path; + visit.file_path = null; + item.node.start_line = visit.start_line; + item.node.end_line = visit.end_line; + item.node.properties_json = visit.properties; + visit.properties = null; + item.hop = visit.hop; + } + c.cbm_zova_graph_visits_free(visits, count); + out.visited = items; + out.visited_count = count; + s.zova_native_topology_ops += 1; + return OK; +} + +/// The native workspace walk follows CALLS only. Every other edge-type set +/// (CALL_REFERENCE, HTTP_CALLS, mixed sets) walks level by level over the +/// workspace edge lookup with the SQL route's hop ordering and limit, and keeps +/// the traversed edges so callers can read their properties. +fn bfsEdgeTypeSelected(edge_type: CStr, edge_types: []const CStr) bool { + if (edge_type == null) return false; + if (edge_types.len == 0) return streq(edge_type, "CALLS"); + for (edge_types) |selected| { + if (selected != null and streq(edge_type, selected)) return true; + } + return false; +} + +fn bfsHopIndex(hops: []const NodeHop, id: i64) ?usize { + for (hops, 0..) |*hop, i| { + if (hop.node.id == id) return i; + } + return null; +} + +fn bfsHopOrder(a: ?*const anyopaque, b: ?*const anyopaque) callconv(.c) c_int { + const x = &@as(*const NodeHop, @ptrCast(@alignCast(a))).node; + const y = &@as(*const NodeHop, @ptrCast(@alignCast(b))).node; + var cmp = c.strcmp(safeStr(x.file_path), safeStr(y.file_path)); + if (cmp == 0) cmp = c.strcmp(safeStr(x.qualified_name), safeStr(y.qualified_name)); + if (cmp == 0) cmp = c.strcmp(safeStr(x.label), safeStr(y.label)); + if (cmp != 0) return cmp; + if (x.start_line != y.start_line) return if (x.start_line < y.start_line) -1 else 1; + if (x.end_line != y.end_line) return if (x.end_line < y.end_line) -1 else 1; + return order(x.id, y.id); +} + +fn storeZovaWorkspaceBfsTyped( + s: *Store, + start_id: i64, + direction: CStr, + edge_types: []const CStr, + max_depth: c_int, + max_results: c_int, + out: *TraverseResult, +) c_int { + const inbound = direction != null and streq(direction, "inbound"); + var hops = Rows(NodeHop).lazy; + var seen = Rows(c.cbm_edge_t).lazy; + var level_start: c_int = 0; + var level_end: c_int = 0; // hops[level_start, level_end) is the frontier + var rc: c_int = OK; + var depth: c_int = 0; + while (depth < max_depth and rc == OK) : (depth += 1) { + const frontier = if (depth == 0) 1 else level_end - level_start; + const next_start = hops.len; + var f: c_int = 0; + while (f < frontier and rc == OK) : (f += 1) { + const from = if (depth == 0) start_id else hops.at(level_start + f).node.id; + var edges: [*c]c.cbm_edge_t = null; + var edge_count: c_int = 0; + rc = storeZovaWorkspaceEdges(s, from, if (inbound) "inbound" else "outbound", null, &edges, &edge_count); + for (span(edges, edge_count)) |*edge| { + if (rc != OK) break; + if (!bfsEdgeTypeSelected(edge.type, edge_types)) continue; + const next = if (inbound) edge.source_id else edge.target_id; + seen.add().* = edge.*; + edge.* = std.mem.zeroes(c.cbm_edge_t); + if (next == start_id or bfsHopIndex(hops.slice(), next) != null) continue; + const hop = hops.add(); + hop.* = std.mem.zeroes(NodeHop); + rc = cbm_store_find_node_by_id(s, next, &hop.node); + if (rc == OK) { + hop.hop = depth + 1; + } else { + hops.len -= 1; + if (rc == NOT_FOUND) rc = OK; + } + } + cbm_store_free_edges(edges, edge_count); + } + if (hops.len - next_start > 1) { // hops stays NULL until the first hop + c.qsort(hops.at(next_start), @intCast(hops.len - next_start), @sizeOf(NodeHop), bfsHopOrder); + } + while (hops.len > max_results and hops.len > 0) { + hops.len -= 1; + cbm_node_free_fields(&hops.at(hops.len).node); + } + level_start = next_start; + level_end = hops.len; + if (level_start == level_end) break; + } + // Keep the edges whose endpoints both survived, as the SQL route does. + const infos: [*c]c.cbm_edge_info_t = if (seen.len != 0) interop.callocN(c.cbm_edge_info_t, @intCast(seen.len)) else null; + var info_count: c_int = 0; + if (infos != null) { + const kept = hops.slice(); + for (seen.slice()) |*edge| { + const si = bfsHopIndex(kept, edge.source_id); + const ti = bfsHopIndex(kept, edge.target_id); + if ((si == null and edge.source_id != start_id) or + (ti == null and edge.target_id != start_id)) continue; + const info = &infos[0..@intCast(seen.len)][@intCast(info_count)]; + info_count += 1; + info.from_name = interop.heapStrdup(if (si) |i| kept[i].node.name else out.root.name); + info.to_name = interop.heapStrdup(if (ti) |i| kept[i].node.name else out.root.name); + info.type = edge.type; + edge.type = null; + info.confidence = 1.0; + info.source_id = edge.source_id; + info.target_id = edge.target_id; + info.properties_json = edge.properties_json; + edge.properties_json = null; + } + } + cbm_store_free_edges(seen.items, seen.len); + out.visited = hops.items; + out.visited_count = hops.len; + out.edges = infos; + out.edge_count = info_count; + return rc; +} + +fn storeBfs( + s_c: ?*Store, + start_id: i64, + direction: CStr, + edge_types_c: [*c]CStr, + edge_type_count: c_int, + max_depth: c_int, + max_results: c_int, + need_edges: bool, + out: *TraverseResult, +) c_int { + out.* = std.mem.zeroes(TraverseResult); + if (s_c) |s| { + if (s.backend) |backend| { + return backend.bfs.?(s.backend_ctx, start_id, direction, edge_types_c, edge_type_count, max_depth, max_results, out); + } + } + + var root = std.mem.zeroes(c.cbm_node_t); + var rc = storeFindCachedZovaWorkspaceNode(s_c, start_id, &root); + if (rc == NOT_FOUND) rc = cbm_store_find_node_by_id(s_c, start_id, &root); + if (rc != OK) return rc; + const s = s_c orelse return ERR; // a NULL store has no root node + out.root = root; + const edge_types = span(edge_types_c, edge_type_count); + + // The native walk returns nodes only; a caller that reads edge + // properties takes the typed walk instead. + rc = if (need_edges) + NOT_FOUND + else + storeZovaWorkspaceBfs(s, start_id, direction, edge_types, max_depth, max_results, out); + if (rc == OK) return rc; + if (s.zova_workspace_query and rc == NOT_FOUND) { + return storeZovaWorkspaceBfsTyped(s, start_id, direction, edge_types, max_depth, max_results, out); + } + if (s.zova_workspace_query) return rc; + + var types_clause_buf: [c.CBM_SZ_512]u8 = undefined; + const types_clause = bfsBuildTypesClause(edge_type_count, &types_clause_buf); + + // Build recursive CTE for BFS + var sql: [c.CBM_SZ_4K]u8 = undefined; + const is_inbound = direction != null and streq(direction, "inbound"); + const join_cond = if (is_inbound) "e.target_id = bfs.node_id" else "e.source_id = bfs.node_id"; + const next_id = if (is_inbound) "e.source_id" else "e.target_id"; + + _ = interop.bprint(&sql, "WITH RECURSIVE bfs(node_id, hop) AS (" ++ + " SELECT {d}, 0" ++ + " UNION" ++ + " SELECT {s}, bfs.hop + 1" ++ + " FROM bfs" ++ + " JOIN edges e ON {s}" ++ + " WHERE e.type IN ({s}) AND bfs.hop < {d}" ++ + "), shortest AS (" ++ + " SELECT node_id, MIN(hop) AS hop FROM bfs " ++ + " WHERE hop > 0 AND node_id != {d} GROUP BY node_id" ++ + ")" ++ + "SELECT DISTINCT n.id, n.project, n.label, n.name, n.qualified_name, " ++ + "n.file_path, n.start_line, n.end_line, n.properties, shortest.hop " ++ + "FROM shortest " ++ + "JOIN nodes n ON n.id = shortest.node_id " ++ + "ORDER BY shortest.hop, n.file_path, n.qualified_name, n.label, n.start_line, " ++ + "n.end_line, n.id " ++ + "LIMIT {d};", .{ start_id, next_id, join_cond, types_clause, max_depth, start_id, max_results }); + + var stmt: ?*Stmt = null; + if (prepare(s.db, &sql, &stmt) != c.SQLITE_OK) { + storeSetErrorSqlite(s, "bfs prepare"); + return ERR; + } + + // Bind edge type parameters + bfsBindEdgeTypes(stmt, edge_types); + + var visited = Rows(NodeHop).init(ST_INIT_CAP_16); + rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + const hop = visited.add(); + scanNode(stmt, &hop.node); + hop.hop = c.sqlite3_column_int(stmt, 9); + } + out.visited = visited.items; + out.visited_count = visited.len; + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(stmt); + return ERR; + } + + _ = c.sqlite3_finalize(stmt); + + // Collect edges between visited nodes (including root) + if (visited.len > 0) { + _ = bfsCollectEdges(s, start_id, visited.slice(), types_clause, edge_types, &out.edges, &out.edge_count); + } else { + out.edges = null; + out.edge_count = 0; + } + + return OK; +} + +pub export fn cbm_store_bfs( + s: ?*Store, + start_id: i64, + direction: CStr, + edge_types: [*c]CStr, + edge_type_count: c_int, + max_depth: c_int, + max_results: c_int, + out: *TraverseResult, +) c_int { + return storeBfs(s, start_id, direction, edge_types, edge_type_count, max_depth, max_results, false, out); +} + +pub export fn cbm_store_bfs_with_edges( + s: ?*Store, + start_id: i64, + direction: CStr, + edge_types: [*c]CStr, + edge_type_count: c_int, + max_depth: c_int, + max_results: c_int, + out: *TraverseResult, +) c_int { + return storeBfs(s, start_id, direction, edge_types, edge_type_count, max_depth, max_results, true, out); +} + +// ── Relationship-trail walk for Cypher variable-length paths ────── +// +// cbm_store_bfs reports each node once, at its shortest distance. A Cypher +// pattern such as [:CALLS*2..2] asks for paths of length 2, which may reach a +// node whose shortest distance is 1, and must not reuse a relationship (a +// self-loop cannot pad a path). So this walk reports every (node, hop) pair +// that some edge-distinct path reaches. +// +// Path enumeration is exponential, so the walk has a row budget: every node +// entered on a path counts one row, the start included. It goes deepest first +// and, among siblings, lower node id first, so a wide first hop cannot use up +// the budget before a deeper match is seen (upstream 0c5ffa50, 0d97729c, +// cd22487c). It reads edges through cbm_store_find_edges_by_{source,target}, +// so the SQLite, Zova workspace and query-backend stores share one semantics. +const ST_TRAIL_ROW_MULTIPLIER = 8; +const ST_TRAIL_MAX_ROWS = 4096; + +fn trailRowLimit(max_results: c_int, max_depth: c_int) c_int { + const base = @as(i64, if (max_results > 0) max_results else ST_INIT_CAP_16) * ST_TRAIL_ROW_MULTIPLIER + 1; + const rows = base *| @as(i64, if (max_depth > 0) max_depth else ST_INIT_CAP_16); + return if (rows > ST_TRAIL_MAX_ROWS) ST_TRAIL_MAX_ROWS else @intCast(rows); +} + +const TrailAdj = struct { + node: i64, + next: [*c]i64 = null, // selected neighbours, lower id first + count: c_int = 0, +}; + +const TrailFrame = struct { + adj: c_int, // index into the adjacency cache + cursor: c_int, // next neighbour to try; the edge taken to reach the child is cursor - 1 +}; + +fn trailNextOrder(a: ?*const anyopaque, b: ?*const anyopaque) callconv(.c) c_int { + const x = @as(*const i64, @ptrCast(@alignCast(a))).*; + const y = @as(*const i64, @ptrCast(@alignCast(b))).*; + return order(x, y); +} + +/// Index of node's adjacency in the cache, loading it on first use. +fn trailAdjacency( + s: ?*Store, + cache: *Rows(TrailAdj), + node: i64, + inbound: bool, + edge_types: []const CStr, + rc: *c_int, +) c_int { + for (cache.slice(), 0..) |*cached, i| { + if (cached.node == node) return @intCast(i); + } + var edges: [*c]c.cbm_edge_t = null; + var edge_count: c_int = 0; + rc.* = if (inbound) + cbm_store_find_edges_by_target(s, node, &edges, &edge_count) + else + cbm_store_find_edges_by_source(s, node, &edges, &edge_count); + if (rc.* == NOT_FOUND) rc.* = OK; + if (rc.* != OK) return c.CBM_NOT_FOUND; + var adj: TrailAdj = .{ .node = node }; + if (edge_count > 0) adj.next = interop.mallocN(i64, @intCast(edge_count)); + if (adj.next != null) { + for (span(edges, edge_count)) |*edge| { + if (!bfsEdgeTypeSelected(edge.type, edge_types)) continue; + adj.next[@intCast(adj.count)] = if (inbound) edge.source_id else edge.target_id; + adj.count += 1; + } + } + cbm_store_free_edges(edges, edge_count); + // glibc declares qsort's base nonnull: an empty adjacency has next == NULL. + if (adj.count > 1) c.qsort(adj.next, @intCast(adj.count), @sizeOf(i64), trailNextOrder); + cache.add().* = adj; + return cache.len - 1; +} + +/// True when the edge (adjacency a, neighbour slot k) is already on the path. +fn trailUsesEdge(path: []const TrailFrame, a: c_int, k: c_int) bool { + for (path) |frame| { + if (frame.adj == a and frame.cursor - 1 == k) return true; + } + return false; +} + +fn trailHopOrder(a: ?*const anyopaque, b: ?*const anyopaque) callconv(.c) c_int { + const x: *const NodeHop = @ptrCast(@alignCast(a)); + const y: *const NodeHop = @ptrCast(@alignCast(b)); + if (x.hop != y.hop) return if (x.hop < y.hop) -1 else 1; + return bfsHopOrder(a, b); +} + +pub export fn cbm_store_bfs_trail( + s: ?*Store, + start_id: i64, + direction: CStr, + edge_types_c: [*c]CStr, + edge_type_count: c_int, + max_depth: c_int, + max_results_c: c_int, + out: *TraverseResult, +) c_int { + out.* = std.mem.zeroes(TraverseResult); + var rc = cbm_store_find_node_by_id(s, start_id, &out.root); + if (rc != OK) return rc; + const max_results = @max(max_results_c, 0); + const inbound = direction != null and streq(direction, "inbound"); + const row_limit = trailRowLimit(max_results, max_depth); + const edge_types = span(edge_types_c, edge_type_count); + + var cache = Rows(TrailAdj).lazy; + const stack_len = @as(usize, @intCast(@max(max_depth, 0))) + 1; + const stack_ptr: ?[*]TrailFrame = interop.mallocN(TrailFrame, stack_len); + const stack = (stack_ptr orelse return ERR)[0..stack_len]; + var hops = Rows(NodeHop).lazy; // distinct (node, hop) pairs; node holds only the id here + var rows: c_int = 1; // the start node + var depth: c_int = 0; + stack[0] = .{ .adj = trailAdjacency(s, &cache, start_id, inbound, edge_types, &rc), .cursor = 0 }; + while (rc == OK and depth >= 0 and stack[0].adj >= 0) { + const f = &stack[@intCast(depth)]; + const adj = cache.at(f.adj); + if (depth >= max_depth or f.cursor >= adj.count) { + depth -= 1; + continue; + } + const k = f.cursor; + f.cursor += 1; + if (trailUsesEdge(stack[0..@intCast(depth)], f.adj, k)) continue; + if (rows >= row_limit) { + out.truncated = true; + break; + } + rows += 1; + const next = adj.next[@intCast(k)]; + var seen = false; + for (hops.slice()) |*hop| { + seen = hop.node.id == next and hop.hop == depth + 1; + if (seen) break; + } + if (!seen) { + const hop = hops.add(); + hop.* = std.mem.zeroes(NodeHop); + hop.node.id = next; + hop.hop = depth + 1; + } + if (depth + 1 < max_depth) { + const child = trailAdjacency(s, &cache, next, inbound, edge_types, &rc); + if (child >= 0) { + depth += 1; + stack[@intCast(depth)] = .{ .adj = child, .cursor = 0 }; + } + } + } + for (cache.slice()) |*cached| c.free(cached.next); + c.free(cache.items); + c.free(stack.ptr); + if (out.truncated) { + var limit_buf: [ST_BUF_16]u8 = undefined; + _ = interop.bprint(&limit_buf, "{d}", .{row_limit}); + interop.logWarn("cypher.trail_truncated", .{ "rows", @as(CStr, &limit_buf), "result", "partial" }); + } + + // Materialize each pair; a node reached at several hops is read once. + var kept: usize = 0; + if (rc == OK) { + const pairs = hops.slice(); + for (pairs) |*pair| { + const id = pair.node.id; + const prior = bfsHopIndex(pairs[0..kept], id); + var node = std.mem.zeroes(c.cbm_node_t); + const nrc = if (prior != null) OK else cbm_store_find_node_by_id(s, id, &node); + if (nrc == NOT_FOUND) continue; + if (nrc != OK) { + rc = nrc; + break; + } + if (prior) |index| { + const p = &pairs[index].node; + node = p.*; + node.project = interop.heapStrdup(p.project); + node.label = interop.heapStrdup(p.label); + node.name = interop.heapStrdup(p.name); + node.qualified_name = interop.heapStrdup(p.qualified_name); + node.file_path = interop.heapStrdup(p.file_path); + node.properties_json = interop.heapStrdup(p.properties_json); + } + pairs[kept] = .{ .node = node, .hop = pair.hop }; + kept += 1; + } + } + hops.len = @intCast(kept); + if (hops.len > 0) c.qsort(hops.items, @intCast(hops.len), @sizeOf(NodeHop), trailHopOrder); + if (hops.len > max_results) { + while (hops.len > max_results) { + hops.len -= 1; + cbm_node_free_fields(&hops.at(hops.len).node); + } + out.truncated = true; + } + out.visited = hops.items; + out.visited_count = hops.len; + return rc; +} + +pub export fn cbm_store_traverse_free(out_c: ?*TraverseResult) void { + const out = out_c orelse return; + // Free root + cbm_node_free_fields(&out.root); + + // Free visited + for (span(out.visited, out.visited_count)) |*h| cbm_node_free_fields(&h.node); + c.free(out.visited); + + // Free edges + for (span(out.edges, out.edge_count)) |*edge| { + interop.strFree(&edge.from_name); + interop.strFree(&edge.to_name); + interop.strFree(&edge.type); + interop.strFree(&edge.properties_json); + } + c.free(out.edges); + + out.* = std.mem.zeroes(TraverseResult); +} + +// ── Impact analysis ────────────────────────────────────────────── + +const Risk = c.cbm_risk_level_t; + +pub export fn cbm_hop_to_risk(hop: c_int) Risk { + return switch (hop) { + 1 => c.CBM_RISK_CRITICAL, + 2 => c.CBM_RISK_HIGH, + 3 => c.CBM_RISK_MEDIUM, + else => c.CBM_RISK_LOW, + }; +} + +pub export fn cbm_risk_label(level: Risk) CStr { + return switch (level) { + c.CBM_RISK_CRITICAL => "CRITICAL", + c.CBM_RISK_HIGH => "HIGH", + c.CBM_RISK_MEDIUM => "MEDIUM", + else => "LOW", + }; +} + +pub export fn cbm_build_impact_summary( + hops: [*c]const NodeHop, + hop_count: c_int, + edges: [*c]const c.cbm_edge_info_t, + edge_count: c_int, +) c.cbm_impact_summary_t { + var s = std.mem.zeroes(c.cbm_impact_summary_t); + for (span(hops, hop_count)) |*hop| { + switch (cbm_hop_to_risk(hop.hop)) { + c.CBM_RISK_CRITICAL => s.critical += 1, + c.CBM_RISK_HIGH => s.high += 1, + c.CBM_RISK_MEDIUM => s.medium += 1, + else => s.low += 1, + } + s.total += 1; + } + for (span(edges, edge_count)) |*edge| { + if (edge.type != null and (streq(edge.type, "HTTP_CALLS") or streq(edge.type, "ASYNC_CALLS"))) { + s.has_cross_service = true; + break; + } + } + return s; +} + +pub export fn cbm_deduplicate_hops(hops_c: [*c]const NodeHop, hop_count: c_int, out: *[*c]NodeHop, out_count: *c_int) c_int { + out.* = null; + out_count.* = 0; + const hops = span(hops_c, hop_count); + if (hops.len == 0) return OK; + + // Simple O(n²) dedup — keep minimum hop per node ID + const result_ptr: ?[*]NodeHop = interop.mallocN(NodeHop, hops.len); + const result = (result_ptr orelse return ERR)[0..hops.len]; + var n: usize = 0; + + for (hops) |*hop| { + if (bfsHopIndex(result[0..n], hop.node.id)) |found| { + if (hop.hop < result[found].hop) result[found].hop = hop.hop; + } else { + result[n] = hop.*; + n += 1; + } + } + + out.* = interop.safeReallocN(NodeHop, result.ptr, n); + out_count.* = @intCast(n); + return OK; +} + +// ── Schema ─────────────────────────────────────────────────────── + +const SCHEMA_MAX_JSON_KEYS = 50; + +const SchemaInfo = c.cbm_schema_info_t; + +/// A one-shot statement; null when prepare fails or the SQL holds no statement. +fn prepareOne(db: ?*c.sqlite3, sql: CStr) ?*Stmt { + var stmt: ?*Stmt = null; + if (prepare(db, sql, &stmt) != c.SQLITE_OK) { + if (stmt != null) _ = c.sqlite3_finalize(stmt); + return null; + } + return stmt; +} + +/// Discover distinct JSON property keys for a table/column via json_each(). +/// Prepends base_cols, then appends up to SCHEMA_MAX_JSON_KEYS from the query. +/// Caller must free the returned array and each string in it. +fn schemaDiscoverProps( + db: ?*c.sqlite3, + sql: CStr, + project: CStr, + filter: CStr, + base_cols: []const CStr, + out_props: *[*c][*c]u8, + out_count: *c_int, +) void { + const pcap = base_cols.len + SCHEMA_MAX_JSON_KEYS; + const props: [*c][*c]u8 = interop.mallocN([*c]u8, pcap); + var pn: usize = 0; + + for (base_cols) |col| { + props[pn] = interop.heapStrdup(col); + pn += 1; + } + + var pstmt: ?*Stmt = null; + if (prepare(db, sql, &pstmt) == c.SQLITE_OK) { + _ = bindText(pstmt, 1, project); + _ = bindText(pstmt, 2, filter); + while (c.sqlite3_step(pstmt) == c.SQLITE_ROW and pn < pcap) { + props[pn] = columnDup(pstmt, 0); + pn += 1; + } + _ = c.sqlite3_finalize(pstmt); + } + + out_props.* = props; + out_count.* = @intCast(pn); +} + +// Path scoping for architecture / schema (shared). +const arch_blank = " \t\n\r"; + +fn archPathIsSet(path: CStr) bool { + if (path == null) return false; + return std.mem.trimStart(u8, std.mem.span(path), arch_blank).len != 0; +} + +/// Normalizes a path scope into norm_out and writes its `norm/%` LIKE pattern +/// into like_out. False when the path names no scope. +fn archPathPrepare(path_c: CStr, norm_out: []u8, like_out: []u8) bool { + if (!archPathIsSet(path_c) or norm_out.len == 0) return false; + var path = std.mem.trimStart(u8, std.mem.span(path_c), arch_blank); + if (std.mem.startsWith(u8, path, "./")) path = path[2..]; + path = std.mem.trimStart(u8, path, "/"); + if (path.len == 0) return false; + var len = @min(path.len, norm_out.len - 1); + @memcpy(norm_out[0..len], path[0..len]); + while (len > 0 and (norm_out[len - 1] == ' ' or norm_out[len - 1] == '\t' or norm_out[len - 1] == '/')) { + len -= 1; + } + // Collapse duplicate slashes + var w: usize = 0; + for (norm_out[0..len]) |ch| { + if (ch == '/' and w > 0 and norm_out[w - 1] == '/') continue; + norm_out[w] = ch; + w += 1; + } + norm_out[w] = 0; + if (w == 0) return false; + _ = interop.bprint(like_out, "{s}/%", .{norm_out[0..w]}); + return true; +} + +const arch_path_scope_sql = " AND (file_path = ? OR file_path LIKE ?)"; + +fn archBindPathScope(stmt: ?*Stmt, exact_idx: c_int, like_idx: c_int, norm: CStr, like_pat: CStr) void { + _ = bindText(stmt, exact_idx, norm); + _ = bindText(stmt, like_idx, like_pat); +} + +pub export fn cbm_store_arch_path_scoped(path: CStr) bool { + var norm: [c.CBM_SZ_512]u8 = undefined; + var like: [c.CBM_SZ_512 + 4]u8 = undefined; + return archPathPrepare(path, &norm, &like); +} + +pub export fn cbm_store_normalize_arch_path(path: CStr, norm_out: [*c]u8, norm_sz: usize) bool { + if (norm_out == null) return false; + var like: [c.CBM_SZ_512 + 4]u8 = undefined; + return archPathPrepare(path, norm_out[0..norm_sz], &like); +} + +/// Ids of the nodes view rows under a prepared path scope, sorted ascending. +fn storeScopeNodeIds(s: *Store, project: CStr, norm: CStr, like: CStr, out: *[*c]i64, out_count: *c_int) c_int { + out.* = null; + out_count.* = 0; + var stmt: ?*Stmt = null; + if (prepare(s.db, "SELECT id FROM nodes WHERE project = ?1 " ++ + "AND (file_path = ?2 OR file_path LIKE ?3) ORDER BY id;", &stmt) != c.SQLITE_OK) + { + storeSetErrorSqlite(s, "scope_node_ids"); + return ERR; + } + _ = bindText(stmt, 1, project); + archBindPathScope(stmt, 2, 3, norm, like); + var cap: usize = 0; + var n: usize = 0; + var ids: [*c]i64 = null; + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + if (n == cap) { + cap = if (cap != 0) cap * ST_GROWTH else c.CBM_SZ_256; + const grown = interop.reallocN(i64, ids, cap); + if (grown == null) { + rc = c.SQLITE_NOMEM; + break; + } + ids = grown; + } + ids[n] = c.sqlite3_column_int64(stmt, 0); + n += 1; + } + _ = c.sqlite3_finalize(stmt); + if (rc != c.SQLITE_DONE) { + c.free(ids); + storeSetError(s, "scope_node_ids"); + return ERR; + } + out.* = ids; + out_count.* = @intCast(n); + return OK; +} + +fn storeTypeCountCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + const left: *const c.cbm_type_count_t = @ptrCast(@alignCast(left_ptr)); + const right: *const c.cbm_type_count_t = @ptrCast(@alignCast(right_ptr)); + if (left.count != right.count) return if (left.count > right.count) -1 else 1; + return c.strcmp(left.type, right.type); +} + +/// Edge type counts of a Zova workspace store, most frequent first. With +/// scoped, only edges whose two ends are in the sorted scope ids count. +fn storeZovaEdgeTypeCounts( + s: *Store, + scoped: bool, + scope: []const i64, + out: *[*c]c.cbm_type_count_t, + out_count: *c_int, +) c_int { + out.* = null; + out_count.* = 0; + if (storeZovaEdges(s) != OK) return ERR; + const types = s.zovaEdgeTypes(); + const counts_ptr: [*c]c_int = interop.callocN(c_int, types.len + 1); + const arr_ptr: [*c]c.cbm_type_count_t = interop.callocN(c.cbm_type_count_t, types.len + 1); + if (counts_ptr == null or arr_ptr == null) { + c.free(counts_ptr); + c.free(arr_ptr); + return ERR; + } + const counts = counts_ptr[0 .. types.len + 1]; + const arr = arr_ptr[0 .. types.len + 1]; + for (s.zovaEdges()) |*e| { + if (!scoped or (storeIdIn(scope, e.source_key) and storeIdIn(scope, e.target_key))) { + counts[@intCast(e.type)] += 1; + } + } + var n: usize = 0; + for (types, counts[0..types.len]) |name, count| { + if (count == 0) continue; + arr[n].type = interop.heapStrdup(name); + arr[n].count = count; + if (arr[n].type == null) { + for (arr[0..n]) |*item| interop.strFree(&item.type); + c.free(counts_ptr); + c.free(arr_ptr); + return ERR; + } + n += 1; + } + c.free(counts_ptr); + c.qsort(arr_ptr, n, @sizeOf(c.cbm_type_count_t), storeTypeCountCompare); + out.* = arr_ptr; + out_count.* = @intCast(n); + return OK; +} + +const StoreEdgeKeys = struct { + schema: *SchemaInfo, + keys: [*c][*c][*c]u8, + key_counts: [*c]c_int, + key_caps: [*c]c_int, + failed: bool = false, +}; + +fn storeEdgeKeysVisit( + source_key: i64, + target_key: i64, + type_c: [*c]const u8, + type_len: usize, + properties: [*c]const u8, + properties_len: usize, + context_ptr: ?*anyopaque, +) callconv(.c) c_int { + _ = source_key; + _ = target_key; + const context: *StoreEdgeKeys = @ptrCast(@alignCast(context_ptr)); + if (properties_len == 0 or std.mem.eql(u8, properties[0..properties_len], "{}")) return 0; + const edge_type: []const u8 = if (type_len == 0) "" else type_c[0..type_len]; + const t = for (span(context.schema.edge_types, context.schema.edge_type_count), 0..) |*entry, i| { + if (std.mem.eql(u8, std.mem.span(entry.type), edge_type)) break i; + } else return 0; + const doc = c.yyjson_read(properties, properties_len, 0); + const root = c.yyjson_doc_get_root(doc); + var iter: c.yyjson_obj_iter = undefined; + if (c.yyjson_is_obj(root) and c.yyjson_obj_iter_init(root, &iter)) { + while (@as(?*c.yyjson_val, c.yyjson_obj_iter_next(&iter))) |key| { + const k = c.yyjson_get_str(key); + if (k == null) continue; + const seen = for (span(context.keys[t], context.key_counts[t])) |existing| { + if (streq(existing, k)) break true; + } else false; + if (seen) continue; + if (context.key_counts[t] == context.key_caps[t]) { + const cap = if (context.key_caps[t] != 0) context.key_caps[t] * ST_GROWTH else ST_INIT_CAP_8; + const grown = interop.reallocN([*c]u8, context.keys[t], @intCast(cap)); + if (grown == null) { + context.failed = true; + break; + } + context.keys[t] = grown; + context.key_caps[t] = cap; + } + const copy = interop.heapStrdup(k); + if (copy == null) { + context.failed = true; + break; + } + context.keys[t][@intCast(context.key_counts[t])] = copy; + context.key_counts[t] += 1; + } + } + c.yyjson_doc_free(doc); + return if (context.failed) c.CBM_NOT_FOUND else 0; +} + +/// Property keys of each edge type in schema, as getSchemaImpl lists them: +/// the base columns, then up to SCHEMA_MAX_JSON_KEYS distinct JSON keys in +/// key order, read from the edge payloads in the Zova graph. +fn storeZovaEdgePropertyKeys(s: *Store, schema: *SchemaInfo, base_cols: []const CStr) c_int { + const edge_types = span(schema.edge_types, schema.edge_type_count); + var context: StoreEdgeKeys = .{ + .schema = schema, + .keys = interop.callocN([*c][*c]u8, edge_types.len + 1), + .key_counts = interop.callocN(c_int, edge_types.len + 1), + .key_caps = interop.callocN(c_int, edge_types.len + 1), + }; + var rc: c_int = if (context.keys != null and context.key_counts != null and context.key_caps != null and + c.cbm_zova_repository_visit_edges(s.zova_repository, &s.zova_graph_workspace_id, null, storeEdgeKeysVisit, &context) == OK) + OK + else + ERR; + for (edge_types, 0..) |*edge_type, t| { + const found: c_int = if (context.key_counts != null) context.key_counts[t] else 0; + const keys: [*c][*c]u8 = if (context.keys != null) context.keys[t] else null; + if (rc == OK) { + if (found > 1) c.qsort(@ptrCast(keys), @intCast(found), @sizeOf([*c]u8), storeNameCompare); + const take: c_int = @min(found, SCHEMA_MAX_JSON_KEYS); + const props: [*c][*c]u8 = interop.mallocN([*c]u8, base_cols.len + @as(usize, @intCast(take)) + 1); + if (props != null) { + var pn: usize = 0; + for (base_cols) |col| { + props[pn] = interop.heapStrdup(col); + pn += 1; + } + for (span(keys, take)) |*key| { + props[pn] = key.*; + key.* = null; + pn += 1; + } + edge_type.properties = props; + edge_type.property_count = @intCast(pn); + } else { + rc = ERR; + } + } + freeStrings(keys, found); + } + c.free(@ptrCast(context.keys)); + c.free(context.key_counts); + c.free(context.key_caps); + if (rc != OK) storeSetError(s, "zova edge property keys"); + return rc; +} + +/// COUNT(*) of a query that binds the project and a prepared path scope. +fn scopedCount(s: *Store, sql: CStr, project: CStr, norm: CStr, like: CStr) c_int { + const stmt = prepareOne(s.db, sql) orelse return ERR; + _ = bindText(stmt, 1, project); + archBindPathScope(stmt, 2, 3, norm, like); + var n: c_int = 0; + if (c.sqlite3_step(stmt) == c.SQLITE_ROW) n = c.sqlite3_column_int(stmt, 0); + _ = c.sqlite3_finalize(stmt); + return n; +} + +pub export fn cbm_store_count_nodes_scoped(s_c: ?*Store, project_c: CStr, path: CStr) c_int { + const s = s_c orelse return 0; + if (s.db == null or project_c == null) return 0; + var project = project_c; + if (storeLegacyScope(s, &project) != OK) return ERR; + var norm: [c.CBM_SZ_512]u8 = undefined; + var like: [c.CBM_SZ_512]u8 = undefined; + if (!archPathPrepare(path, &norm, &like)) return cbm_store_count_nodes(s, project); + return scopedCount(s, "SELECT COUNT(*) FROM nodes WHERE project = ?1 " ++ + "AND (file_path = ?2 OR file_path LIKE ?3);", project, &norm, &like); +} + +pub export fn cbm_store_count_edges_scoped(s_c: ?*Store, project_c: CStr, path: CStr) c_int { + const s = s_c orelse return 0; + if (s.db == null or project_c == null) return 0; + var project = project_c; + if (storeLegacyScope(s, &project) != OK) return ERR; + var norm: [c.CBM_SZ_512]u8 = undefined; + var like: [c.CBM_SZ_512]u8 = undefined; + if (!archPathPrepare(path, &norm, &like)) return cbm_store_count_edges(s, project); + if (storeIsZovaWorkspace(s)) { + var scope_ptr: [*c]i64 = null; + var scope_count: c_int = 0; + defer c.free(scope_ptr); + if (storeScopeNodeIds(s, project, &norm, &like, &scope_ptr, &scope_count) != OK or + storeZovaEdges(s) != OK) + { + return ERR; + } + const scope = span(scope_ptr, scope_count); + var n: c_int = 0; + for (s.zovaEdges()) |*e| { + n += @intFromBool(storeIdIn(scope, e.source_key) and storeIdIn(scope, e.target_key)); + } + return n; + } + return scopedCount(s, "SELECT COUNT(*) FROM edges e WHERE e.project = ?1 " ++ + "AND EXISTS (SELECT 1 FROM nodes ns WHERE ns.id = e.source_id AND ns.project = ?1 " ++ + "AND (ns.file_path = ?2 OR ns.file_path LIKE ?3)) " ++ + "AND EXISTS (SELECT 1 FROM nodes nt WHERE nt.id = e.target_id AND nt.project = ?1 " ++ + "AND (nt.file_path = ?2 OR nt.file_path LIKE ?3));", project, &norm, &like); +} + +/// Steps stmt to completion, reading (name, COUNT(*)) rows into a malloc'd +/// array of cbm_label_count_t or cbm_type_count_t, and finalizes it. +/// CBM_NOT_FOUND when an allocation fails (nothing returned); CBM_STORE_ERR +/// when the scan aborts (the rows read so far are returned). +fn schemaScanCounts( + comptime T: type, + comptime name: []const u8, + s: *Store, + stmt: ?*Stmt, + out: *[*c]T, + out_count: *c_int, +) c_int { + var cap: usize = ST_INIT_CAP_8; + var n: usize = 0; + var arr: [*c]T = interop.mallocN(T, cap); + if (arr == null) { + _ = c.sqlite3_finalize(stmt); + return c.CBM_NOT_FOUND; + } + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + if (n >= cap) { + const grown = interop.reallocN(T, arr, cap * ST_GROWTH); + if (grown == null) { + for (arr[0..n]) |*item| interop.strFree(&@field(item, name)); + c.free(arr); + _ = c.sqlite3_finalize(stmt); + return c.CBM_NOT_FOUND; + } + arr = grown; + cap *= ST_GROWTH; + } + var item = std.mem.zeroes(T); + @field(item, name) = columnDup(stmt, 0); + item.count = c.sqlite3_column_int(stmt, 1); + arr[n] = item; + n += 1; + } + if (rc != c.SQLITE_DONE) storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(stmt); + out.* = arr; + out_count.* = @intCast(n); + return if (rc != c.SQLITE_DONE) ERR else OK; +} + +const node_base_cols = [_]CStr{ "name", "qualified_name", "file_path", "start_line", "end_line" }; +const edge_base_cols = [_]CStr{ "source_id", "target_id" }; + +/// with_props=false skips the per-label/per-type JSON property-key discovery: +/// those json_each() scans walk EVERY row of each label/type (minutes-scale on +/// multi-million-node graphs) and get_architecture only needs the counts. +fn getSchemaImpl(s_c: ?*Store, project_c: CStr, out: *SchemaInfo, with_props: bool) c_int { + out.* = std.mem.zeroes(SchemaInfo); + const s = s_c orelse return c.CBM_NOT_FOUND; + if (s.db == null) return c.CBM_NOT_FOUND; + var project = project_c; + if (storeLegacyScope(s, &project) != OK) return ERR; + + // Node labels + { + const stmt = prepareOne(s.db, "SELECT label, COUNT(*) FROM nodes WHERE project = ?1 GROUP BY label " ++ + "ORDER BY COUNT(*) DESC;") orelse return c.CBM_NOT_FOUND; + _ = bindText(stmt, 1, project); + const rc = schemaScanCounts(c.cbm_label_count_t, "label", s, stmt, &out.node_labels, &out.node_label_count); + if (rc != OK) return rc; + } + + // Node label property keys: base columns + distinct JSON property keys per label + if (with_props) { + const prop_sql = "SELECT DISTINCT je.key " ++ + "FROM nodes, json_each(nodes.properties) AS je " ++ + "WHERE nodes.project = ?1 AND nodes.label = ?2 " ++ + " AND nodes.properties != '{}' " ++ + "ORDER BY je.key " ++ + "LIMIT 50;"; + + for (span(out.node_labels, out.node_label_count)) |*label| { + schemaDiscoverProps(s.db, prop_sql, project, label.label, &node_base_cols, &label.properties, &label.property_count); + } + } + + // Edge types + if (storeIsZovaWorkspace(s)) { + if (storeZovaEdgeTypeCounts(s, false, &.{}, &out.edge_types, &out.edge_type_count) != OK) { + cbm_store_schema_free(out); + return ERR; + } + } else { + const stmt = prepareOne(s.db, "SELECT type, COUNT(*) FROM edges WHERE project = ?1 GROUP BY type ORDER " ++ + "BY COUNT(*) DESC;") orelse { + cbm_store_schema_free(out); + return c.CBM_NOT_FOUND; + }; + _ = bindText(stmt, 1, project); + const rc = schemaScanCounts(c.cbm_type_count_t, "type", s, stmt, &out.edge_types, &out.edge_type_count); + if (rc == c.CBM_NOT_FOUND) cbm_store_schema_free(out); + if (rc != OK) return rc; + } + + // Edge type property keys: base columns + distinct JSON property keys per type + if (with_props and storeIsZovaWorkspace(s)) { + if (storeZovaEdgePropertyKeys(s, out, &edge_base_cols) != OK) { + cbm_store_schema_free(out); + return ERR; + } + } else if (with_props) { + const prop_sql = "SELECT DISTINCT je.key " ++ + "FROM edges, json_each(edges.properties) AS je " ++ + "WHERE edges.project = ?1 AND edges.type = ?2 " ++ + " AND edges.properties != '{}' " ++ + "ORDER BY je.key " ++ + "LIMIT 50;"; + + for (span(out.edge_types, out.edge_type_count)) |*edge_type| { + schemaDiscoverProps(s.db, prop_sql, project, edge_type.type, &edge_base_cols, &edge_type.properties, &edge_type.property_count); + } + } + + return OK; +} + +pub export fn cbm_store_get_schema(s: ?*Store, project: CStr, out: *SchemaInfo) c_int { + return getSchemaImpl(s, project, out, true); +} + +pub export fn cbm_store_get_schema_counts(s: ?*Store, project: CStr, out: *SchemaInfo) c_int { + return getSchemaImpl(s, project, out, false); +} + +pub export fn cbm_store_get_schema_counts_scoped(s_c: ?*Store, project_c: CStr, path: CStr, out: *SchemaInfo) c_int { + out.* = std.mem.zeroes(SchemaInfo); + const s = s_c orelse return c.CBM_NOT_FOUND; + if (s.db == null) return c.CBM_NOT_FOUND; + var project = project_c; + if (storeLegacyScope(s, &project) != OK) return ERR; + var norm: [c.CBM_SZ_512]u8 = undefined; + var like: [c.CBM_SZ_512]u8 = undefined; + if (!archPathPrepare(path, &norm, &like)) return getSchemaImpl(s, project, out, false); + + { + const stmt = prepareOne(s.db, "SELECT label, COUNT(*) FROM nodes WHERE project = ?1" ++ arch_path_scope_sql ++ + " GROUP BY label ORDER BY COUNT(*) DESC;") orelse return c.CBM_NOT_FOUND; + _ = bindText(stmt, 1, project); + archBindPathScope(stmt, 2, 3, &norm, &like); + const rc = schemaScanCounts(c.cbm_label_count_t, "label", s, stmt, &out.node_labels, &out.node_label_count); + if (rc != OK) return rc; + } + + if (storeIsZovaWorkspace(s)) { + var scope: [*c]i64 = null; + var scope_count: c_int = 0; + var rc = storeScopeNodeIds(s, project, &norm, &like, &scope, &scope_count); + if (rc == OK) { + rc = storeZovaEdgeTypeCounts(s, true, span(scope, scope_count), &out.edge_types, &out.edge_type_count); + } + c.free(scope); + if (rc != OK) { + cbm_store_schema_free(out); + return ERR; + } + } else { + const stmt = prepareOne(s.db, "SELECT e.type, COUNT(*) FROM edges e WHERE e.project = ?1 " ++ + "AND EXISTS (SELECT 1 FROM nodes ns WHERE ns.id = e.source_id AND ns.project = ?1 " ++ + "AND (ns.file_path = ?2 OR ns.file_path LIKE ?3)) " ++ + "AND EXISTS (SELECT 1 FROM nodes nt WHERE nt.id = e.target_id AND nt.project = ?1 " ++ + "AND (nt.file_path = ?2 OR nt.file_path LIKE ?3)) " ++ + "GROUP BY e.type ORDER BY COUNT(*) DESC;") orelse { + cbm_store_schema_free(out); + return c.CBM_NOT_FOUND; + }; + _ = bindText(stmt, 1, project); + archBindPathScope(stmt, 2, 3, &norm, &like); + const rc = schemaScanCounts(c.cbm_type_count_t, "type", s, stmt, &out.edge_types, &out.edge_type_count); + if (rc == c.CBM_NOT_FOUND) cbm_store_schema_free(out); + if (rc != OK) return rc; + } + + return OK; +} + +/// Frees an array of malloc'd strings held through const pointers. +fn freeConstStrings(items: [*c]CStr, count: c_int) void { + for (span(items, count)) |*item| interop.strFree(item); + c.free(@ptrCast(items)); +} + +pub export fn cbm_store_schema_free(out_c: ?*SchemaInfo) void { + const out = out_c orelse return; + for (span(out.node_labels, out.node_label_count)) |*label| { + interop.strFree(&label.label); + freeStrings(label.properties, label.property_count); + } + c.free(out.node_labels); + + for (span(out.edge_types, out.edge_type_count)) |*edge_type| { + interop.strFree(&edge_type.type); + freeStrings(edge_type.properties, edge_type.property_count); + } + c.free(out.edge_types); + + freeConstStrings(out.rel_patterns, out.rel_pattern_count); + freeConstStrings(out.sample_func_names, out.sample_func_count); + freeConstStrings(out.sample_class_names, out.sample_class_count); + freeConstStrings(out.sample_qns, out.sample_qn_count); + + out.* = std.mem.zeroes(SchemaInfo); +} + +// ── Architecture helpers ───────────────────────────────────────── + +const ArchInfo = c.cbm_architecture_info_t; + +/// A malloc'd NUL-terminated copy of text; NULL on OOM. +fn dupSlice(text: []const u8) [*c]u8 { + const copy: [*c]u8 = interop.mallocN(u8, text.len + 1); + if (copy != null) { + @memcpy(copy[0..text.len], text); + copy[text.len] = 0; + } + return copy; +} + +/// Copies a QN segment into buf; null when it is empty or does not fit. +fn qnSegment(buf: *[c.CBM_SZ_256]u8, segment: []const u8) ?CStr { + if (segment.len == 0 or segment.len >= buf.len) return null; + @memcpy(buf[0..segment.len], segment); + buf[segment.len] = 0; + return buf; +} + +/// Extract sub-package from QN: project.dir1.dir2.sym → dir1 (4+ parts → [2], else [1]) +pub export fn cbm_qn_to_package(qn_c: CStr) CStr { + if (qn_c == null or qn_c[0] == 0) return ""; + const static = struct { + threadlocal var buf: [c.CBM_SZ_256]u8 = undefined; + }; + const qn = std.mem.span(qn_c); + // Find dots and extract segment + var dots: [ST_QN_MAX_DOTS]usize = undefined; + var ndots: usize = 0; + for (qn, 0..) |ch, i| { + if (ndots >= ST_QN_MAX_DOTS) break; + if (ch == '.') { + dots[ndots] = i; + ndots += 1; + } + } + // 4+ segments: return segment[2] + if (ndots >= ST_QN_MIN_DOTS) { + if (qnSegment(&static.buf, qn[dots[1] + 1 .. dots[2]])) |segment| return segment; + } + // 2+ segments: return segment[1] + if (ndots >= 1) { + const end = if (ndots >= 2) dots[1] else qn.len; + if (qnSegment(&static.buf, qn[dots[0] + 1 .. end])) |segment| return segment; + } + return ""; +} + +/// Extract top-level package from QN: project.dir1.rest → dir1 (segment[1]) +pub export fn cbm_qn_to_top_package(qn_c: CStr) CStr { + if (qn_c == null or qn_c[0] == 0) return ""; + const static = struct { + threadlocal var buf: [c.CBM_SZ_256]u8 = undefined; + }; + const qn = std.mem.span(qn_c); + const first_dot = std.mem.findScalar(u8, qn, '.') orelse return ""; + const start = first_dot + 1; + const end = std.mem.findScalarPos(u8, qn, start, '.') orelse qn.len; + return qnSegment(&static.buf, qn[start..end]) orelse ""; +} + +pub export fn cbm_is_test_file_path(fp: CStr) bool { + if (fp == null or fp[0] == 0) return false; + return c.strstr(fp, "test") != null; +} + +/// File extension → language name mapping (table-driven) +const ExtLang = struct { ext: []const u8, lang: [:0]const u8 }; + +const ext_lang_table = [_]ExtLang{ + .{ .ext = ".py", .lang = "Python" }, .{ .ext = ".go", .lang = "Go" }, + .{ .ext = ".js", .lang = "JavaScript" }, .{ .ext = ".jsx", .lang = "JavaScript" }, + .{ .ext = ".ts", .lang = "TypeScript" }, .{ .ext = ".tsx", .lang = "TypeScript" }, + .{ .ext = ".rs", .lang = "Rust" }, .{ .ext = ".java", .lang = "Java" }, + .{ .ext = ".cpp", .lang = "C++" }, .{ .ext = ".cc", .lang = "C++" }, + .{ .ext = ".cxx", .lang = "C++" }, .{ .ext = ".c", .lang = "C" }, + .{ .ext = ".h", .lang = "C" }, .{ .ext = ".cs", .lang = "C#" }, + .{ .ext = ".php", .lang = "PHP" }, .{ .ext = ".lua", .lang = "Lua" }, + .{ .ext = ".scala", .lang = "Scala" }, .{ .ext = ".kt", .lang = "Kotlin" }, + .{ .ext = ".rb", .lang = "Ruby" }, .{ .ext = ".sh", .lang = "Bash" }, + .{ .ext = ".bash", .lang = "Bash" }, .{ .ext = ".zig", .lang = "Zig" }, + .{ .ext = ".ex", .lang = "Elixir" }, .{ .ext = ".exs", .lang = "Elixir" }, + .{ .ext = ".hs", .lang = "Haskell" }, .{ .ext = ".ml", .lang = "OCaml" }, + .{ .ext = ".mli", .lang = "OCaml" }, .{ .ext = ".html", .lang = "HTML" }, + .{ .ext = ".css", .lang = "CSS" }, .{ .ext = ".yaml", .lang = "YAML" }, + .{ .ext = ".yml", .lang = "YAML" }, .{ .ext = ".toml", .lang = "TOML" }, + .{ .ext = ".hcl", .lang = "HCL" }, .{ .ext = ".tf", .lang = "HCL" }, + .{ .ext = ".sql", .lang = "SQL" }, .{ .ext = ".erl", .lang = "Erlang" }, + .{ .ext = ".swift", .lang = "Swift" }, .{ .ext = ".dart", .lang = "Dart" }, + .{ .ext = ".groovy", .lang = "Groovy" }, .{ .ext = ".pl", .lang = "Perl" }, + .{ .ext = ".r", .lang = "R" }, .{ .ext = ".scss", .lang = "SCSS" }, + .{ .ext = ".vue", .lang = "Vue" }, .{ .ext = ".svelte", .lang = "Svelte" }, +}; + +fn extToLang(ext_opt: ?[]const u8) ?[:0]const u8 { + const ext = ext_opt orelse return null; + for (&ext_lang_table) |*entry| { + if (std.mem.eql(u8, ext, entry.ext)) return entry.lang; + } + return null; +} + +/// Get lowercase file extension from path into buf, dot included. Null if +/// none or too long. +fn fileExtLower(path: CStr, buf: *[c.CBM_SZ_16]u8) ?[]const u8 { + const dot = std.mem.span(c.cbm_path_last_dot(path)); + if (dot.len == 0 or dot.len >= buf.len) return null; + for (dot, buf[0..dot.len]) |ch, *lower| lower.* = std.ascii.toLower(ch); + return buf[0..dot.len]; +} + +/// A path scope prepared for the architecture queries. +const ArchScope = struct { + norm: [c.CBM_SZ_512]u8, + like: [c.CBM_SZ_512]u8, + scoped: bool, + + fn init(path: CStr) ArchScope { + var scope: ArchScope = undefined; + scope.scoped = archPathPrepare(path, &scope.norm, &scope.like); + return scope; + } + + /// Prepares sql — scoped_sql under a path scope — and binds the project + /// as ?1 and the scope as ?2 and ?3. Null when prepare fails. + fn query(scope: *const ArchScope, db: ?*c.sqlite3, sql: CStr, scoped_sql: CStr, project: CStr) ?*Stmt { + const stmt = prepareOne(db, if (scope.scoped) scoped_sql else sql) orelse return null; + _ = bindText(stmt, 1, project); + if (scope.scoped) archBindPathScope(stmt, 2, 3, &scope.norm, &scope.like); + return stmt; + } +}; + +// ── Architecture aspect implementations ───────────────────────── + +fn archLanguages(s: *Store, project: CStr, path: CStr, out: *ArchInfo) c_int { + const scope = ArchScope.init(path); + const base = "SELECT file_path FROM nodes WHERE project=?1 AND label='File'"; + const stmt = scope.query(s.db, base, base ++ arch_path_scope_sql, project) orelse { + storeSetErrorSqlite(s, "arch_languages"); + return ERR; + }; + + // Count per language using a simple parallel array + var lang_names: [c.CBM_SZ_64][:0]const u8 = undefined; + var lang_counts: [c.CBM_SZ_64]c_int = undefined; + var nlang: usize = 0; + + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + var ext_buf: [c.CBM_SZ_16]u8 = undefined; + const lang = extToLang(fileExtLower(columnText(stmt, 0), &ext_buf)) orelse continue; + const found = for (lang_names[0..nlang], 0..) |name, i| { + if (std.mem.eql(u8, name, lang)) break i; + } else null; + if (found) |i| { + lang_counts[i] += 1; + } else if (nlang < lang_names.len) { + lang_names[nlang] = lang; + lang_counts[nlang] = 1; + nlang += 1; + } + } + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(stmt); + return ERR; + } + _ = c.sqlite3_finalize(stmt); + + // Sort by count descending (simple insertion sort) + for (1..@max(nlang, 1)) |i| { + var j = i; + while (j > 0 and lang_counts[j] > lang_counts[j - 1]) : (j -= 1) { + std.mem.swap(c_int, &lang_counts[j], &lang_counts[j - 1]); + std.mem.swap([:0]const u8, &lang_names[j], &lang_names[j - 1]); + } + } + if (nlang > c.CBM_DECIMAL_BASE) nlang = ST_MAX_LANG; + + out.languages = if (nlang > 0) interop.callocN(c.cbm_language_count_t, nlang) else null; + out.language_count = @intCast(nlang); + for (0..nlang) |i| { + out.languages[i] = .{ .language = interop.heapStrdup(lang_names[i].ptr), .file_count = lang_counts[i] }; + } + return OK; +} + +fn archEntryPoints(s: *Store, project: CStr, path: CStr, out: *ArchInfo) c_int { + const scope = ArchScope.init(path); + const base = "SELECT name, qualified_name, file_path FROM nodes " ++ + "WHERE project=?1 AND json_extract(properties, '$.is_entry_point') = 1 " ++ + "AND (json_extract(properties, '$.is_test') IS NULL OR " ++ + "json_extract(properties, '$.is_test') != 1) " ++ + "AND file_path NOT LIKE '%test%'"; + const stmt = scope.query(s.db, base ++ " LIMIT 20", base ++ arch_path_scope_sql ++ " LIMIT 20", project) orelse { + storeSetErrorSqlite(s, "arch_entry_points"); + return ERR; + }; + + var arr = Rows(c.cbm_entry_point_t).init(ST_INIT_CAP_8); + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + arr.add().* = .{ + .name = columnDup(stmt, 0), + .qualified_name = columnDup(stmt, 1), + .file = columnDup(stmt, 2), + }; + } + if (rc != c.SQLITE_DONE) storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(stmt); + out.entry_points = arr.items; + out.entry_point_count = arr.len; + return if (rc != c.SQLITE_DONE) ERR else OK; +} + +/// Extract a JSON string value from a simple JSON object by key name. +fn extractJsonStringProp(json_c: CStr, key: []const u8) [*c]u8 { + if (json_c == null) return null; + const json = std.mem.span(json_c); + const at = std.mem.find(u8, json, key) orelse return null; + const open = std.mem.findScalarPos(u8, json, at + key.len, '"') orelse return null; + const close = std.mem.findScalarPos(u8, json, open + 1, '"') orelse return null; + const value = json[open + 1 .. close]; + if (value.len >= c.CBM_SZ_256) return null; + return dupSlice(value); +} + +fn archRoutes(s: *Store, project: CStr, path: CStr, out: *ArchInfo) c_int { + const scope = ArchScope.init(path); + const base = "SELECT name, properties, COALESCE(file_path, '') FROM nodes " ++ + "WHERE project=?1 AND label='Route' " ++ + "AND (json_extract(properties, '$.is_test') IS NULL OR " ++ + "json_extract(properties, '$.is_test') != 1)"; + const stmt = scope.query(s.db, base ++ " LIMIT 20", base ++ arch_path_scope_sql ++ " LIMIT 20", project) orelse { + storeSetErrorSqlite(s, "arch_routes"); + return ERR; + }; + + var arr = Rows(c.cbm_route_info_t).init(ST_INIT_CAP_8); + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + const name = columnText(stmt, 0); + const props = columnText(stmt, 1); + const fp = columnText(stmt, 2); + if (cbm_is_test_file_path(fp)) continue; + + const route = arr.add(); + route.* = .{ + .method = interop.heapStrdup(""), + .path = interop.heapStrdup(name), + .handler = interop.heapStrdup(""), + }; + + var val = extractJsonStringProp(props, "\"method\""); + if (val != null) { + interop.strFree(&route.method); + route.method = val; + } + val = extractJsonStringProp(props, "\"path\""); + if (val != null) { + interop.strFree(&route.path); + route.path = val; + } + val = extractJsonStringProp(props, "\"handler\""); + if (val != null) { + interop.strFree(&route.handler); + route.handler = val; + } + } + if (rc != c.SQLITE_DONE) storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(stmt); + out.routes = arr.items; + out.route_count = arr.len; + return if (rc != c.SQLITE_DONE) ERR else OK; +} + +const StoreIdPair = extern struct { + source_id: i64, + target_id: i64, +}; + +/// The project's CALLS edges as (source, target) node ids: from the Zova edge +/// list for a workspace store, otherwise from the edges table. +fn storeCallsPairs(s: *Store, project: CStr, out: *[*c]StoreIdPair, out_count: *c_int) c_int { + out.* = null; + out_count.* = 0; + var cap: usize = 0; + var n: usize = 0; + var pairs: [*c]StoreIdPair = null; + if (storeIsZovaWorkspace(s)) { + if (storeZovaEdges(s) != OK) return ERR; + const t = storeZovaEdgeTypeFind(s, "CALLS"); + if (t >= 0) { + for (s.zovaEdges()) |*edge| cap += @intFromBool(edge.type == t); + } + if (cap != 0) { + pairs = interop.mallocN(StoreIdPair, cap); + if (pairs == null) return ERR; + for (s.zovaEdges()) |*edge| { + if (edge.type != t) continue; + pairs[n] = .{ .source_id = edge.source_key, .target_id = edge.target_key }; + n += 1; + } + } + out.* = pairs; + out_count.* = @intCast(n); + return OK; + } + var stmt: ?*Stmt = null; + if (prepare(s.db, "SELECT source_id, target_id FROM edges WHERE project=?1 " ++ + "AND type='CALLS'", &stmt) != c.SQLITE_OK) + { + storeSetErrorSqlite(s, "calls_pairs"); + return ERR; + } + _ = bindText(stmt, 1, project); + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + if (n == cap) { + cap = if (cap != 0) cap * ST_GROWTH else c.CBM_SZ_256; + const grown = interop.reallocN(StoreIdPair, pairs, cap); + if (grown == null) { + rc = c.SQLITE_NOMEM; + break; + } + pairs = grown; + } + pairs[n] = .{ .source_id = c.sqlite3_column_int64(stmt, 0), .target_id = c.sqlite3_column_int64(stmt, 1) }; + n += 1; + } + _ = c.sqlite3_finalize(stmt); + if (rc != c.SQLITE_DONE) { + c.free(pairs); + storeSetError(s, "calls_pairs"); + return ERR; + } + out.* = pairs; + out_count.* = @intCast(n); + return OK; +} + +/// Hotspot candidates: non-test functions and methods. +const ARCH_HOTSPOT_FILTER = + "n.project=?1 AND n.label IN ('Function', 'Method') " ++ + "AND (json_extract(n.properties, '$.is_test') IS NULL OR " ++ + "json_extract(n.properties, '$.is_test') != 1) " ++ + "AND n.file_path NOT LIKE '%test%'"; + +/// Hotspots of a Zova workspace store: fan-in counted from the CALLS edges of +/// the edge list, ties by node id, top 10. +fn archHotspotsZova(s: *Store, project: CStr, scope: *const ArchScope, out: *ArchInfo) c_int { + var calls: [*c]StoreIdPair = null; + var call_count: c_int = 0; + if (storeCallsPairs(s, project, &calls, &call_count) != OK) return ERR; + const targets_ptr: [*c]i64 = interop.mallocN(i64, @as(usize, @intCast(call_count)) + 1); + if (targets_ptr == null) { + c.free(calls); + return ERR; + } + defer c.free(targets_ptr); + const targets = targets_ptr[0..@intCast(call_count)]; + for (targets, span(calls, call_count)) |*target, *call| target.* = call.target_id; + c.free(calls); + c.qsort(targets_ptr, targets.len, @sizeOf(i64), storeIdCompare); + + const sql = "SELECT n.id, n.name, n.qualified_name FROM nodes n WHERE " ++ ARCH_HOTSPOT_FILTER; + const stmt = scope.query( + s.db, + sql ++ " ORDER BY n.id", + sql ++ " AND (n.file_path = ?2 OR n.file_path LIKE ?3) ORDER BY n.id", + project, + ) orelse { + storeSetErrorSqlite(s, "arch_hotspots"); + return ERR; + }; + const HOTSPOT_TOP = 10; + const arr_ptr: [*c]c.cbm_hotspot_t = interop.callocN(c.cbm_hotspot_t, HOTSPOT_TOP); + var n: usize = 0; + var t: usize = 0; + var rc: c_int = c.SQLITE_DONE; + if (arr_ptr != null) { + const arr = arr_ptr[0..HOTSPOT_TOP]; + rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + const id = c.sqlite3_column_int64(stmt, 0); + while (t < targets.len and targets[t] < id) t += 1; + var fan_in: c_int = 0; + while (t < targets.len and targets[t] == id) { + fan_in += 1; + t += 1; + } + // Rows come in id order, so an equal fan-in never displaces. + if (fan_in == 0 or (n == HOTSPOT_TOP and fan_in <= arr[n - 1].fan_in)) continue; + var at = n - @intFromBool(n == HOTSPOT_TOP); + if (n < HOTSPOT_TOP) n += 1; + interop.strFree(&arr[at].name); + interop.strFree(&arr[at].qualified_name); + while (at > 0 and arr[at - 1].fan_in < fan_in) : (at -= 1) arr[at] = arr[at - 1]; + arr[at] = .{ + .name = columnDup(stmt, 1), + .qualified_name = columnDup(stmt, 2), + .fan_in = fan_in, + }; + } + } + _ = c.sqlite3_finalize(stmt); + if (arr_ptr == null or rc != c.SQLITE_DONE) { + for (span(arr_ptr, @intCast(n))) |*hotspot| { + interop.strFree(&hotspot.name); + interop.strFree(&hotspot.qualified_name); + } + c.free(arr_ptr); + storeSetError(s, "arch_hotspots"); + return ERR; + } + out.hotspots = arr_ptr; + out.hotspot_count = @intCast(n); + return OK; +} + +fn archHotspots(s: *Store, project: CStr, path: CStr, out: *ArchInfo) c_int { + const scope = ArchScope.init(path); + if (storeIsZovaWorkspace(s)) return archHotspotsZova(s, project, &scope, out); + const base = "SELECT n.name, n.qualified_name, COUNT(*) as fan_in " ++ + "FROM nodes n JOIN edges e ON e.target_id = n.id AND e.type = 'CALLS' " ++ + "WHERE " ++ ARCH_HOTSPOT_FILTER; + const stmt = scope.query( + s.db, + base ++ " GROUP BY n.id ORDER BY fan_in DESC LIMIT 10", + base ++ " AND (n.file_path = ?2 OR n.file_path LIKE ?3) " ++ + "GROUP BY n.id ORDER BY fan_in DESC LIMIT 10", + project, + ) orelse { + storeSetErrorSqlite(s, "arch_hotspots"); + return ERR; + }; + + var arr = Rows(c.cbm_hotspot_t).init(ST_INIT_CAP_8); + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + arr.add().* = .{ + .name = columnDup(stmt, 0), + .qualified_name = columnDup(stmt, 1), + .fan_in = c.sqlite3_column_int(stmt, 2), + }; + } + if (rc != c.SQLITE_DONE) storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(stmt); + out.hotspots = arr.items; + out.hotspot_count = arr.len; + return if (rc != c.SQLITE_DONE) ERR else OK; +} + +/// Look up package name for a node ID in the parallel arrays. nids must be +/// sorted ascending (the node query orders by id) — a linear scan here is +/// O(E×N) across the edge loop and spun for >10 minutes on Linux-kernel-sized +/// graphs (~1.4M defs × ~1.4M CALLS edges). +fn lookupPkg(nids: []const i64, npkgs: []const [*c]u8, id: i64) CStr { + var lo: usize = 0; + var hi: usize = nids.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + if (nids[mid] == id) return npkgs[mid]; + if (nids[mid] < id) { + lo = mid + 1; + } else { + hi = mid; + } + } + return null; +} + +/// Cross-package boundaries accumulated as parallel arrays. +const BoundaryAccum = struct { + froms: [c.CBM_SZ_32][*c]u8 = undefined, + tos: [c.CBM_SZ_32][*c]u8 = undefined, + counts: [c.CBM_SZ_32]c_int = undefined, + n: usize = 0, + + /// Accumulate a cross-package boundary into parallel arrays. + fn accum(b: *BoundaryAccum, src_pkg: CStr, tgt_pkg: CStr) void { + for (b.froms[0..b.n], b.tos[0..b.n], b.counts[0..b.n]) |from, to, *count| { + if (streq(from, src_pkg) and streq(to, tgt_pkg)) { + count.* += 1; + return; + } + } + if (b.n < b.counts.len) { + b.froms[b.n] = interop.heapStrdup(src_pkg); + b.tos[b.n] = interop.heapStrdup(tgt_pkg); + b.counts[b.n] = 1; + b.n += 1; + } + } +}; + +fn archBoundaries(s: *Store, project: CStr, path: CStr, out_arr: *[*c]c.cbm_cross_pkg_boundary_t, out_count: *c_int) c_int { + const scope = ArchScope.init(path); + const nbase = "SELECT id, qualified_name, file_path FROM nodes WHERE project=?1 AND label IN " ++ + "('Function','Method','Class')"; + const nstmt = scope.query(s.db, nbase ++ " ORDER BY id", nbase ++ arch_path_scope_sql ++ " ORDER BY id", project) orelse { + storeSetErrorSqlite(s, "arch_boundaries_nodes"); + return ERR; + }; + + var nids = Rows(i64).init(c.CBM_SZ_256); + var npkgs = Rows([*c]u8).init(c.CBM_SZ_256); + defer { + freeStrings(npkgs.items, npkgs.len); + c.free(nids.items); + } + + var rc = c.sqlite3_step(nstmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(nstmt)) { + nids.add().* = c.sqlite3_column_int64(nstmt, 0); + npkgs.add().* = interop.heapStrdup(cbm_qn_to_package(columnText(nstmt, 1))); + } + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(nstmt); + return ERR; + } + _ = c.sqlite3_finalize(nstmt); + + // Scan edges, count cross-package calls + var calls: [*c]StoreIdPair = null; + var call_count: c_int = 0; + if (storeCallsPairs(s, project, &calls, &call_count) != OK) return ERR; + + var b: BoundaryAccum = .{}; + for (span(calls, call_count)) |*call| { + const src_pkg = lookupPkg(nids.slice(), npkgs.slice(), call.source_id); + const tgt_pkg = lookupPkg(nids.slice(), npkgs.slice(), call.target_id); + if (src_pkg == null or tgt_pkg == null or src_pkg[0] == 0 or tgt_pkg[0] == 0 or streq(src_pkg, tgt_pkg)) { + continue; + } + b.accum(src_pkg, tgt_pkg); + } + c.free(calls); + + // Sort by count descending + for (1..@max(b.n, 1)) |i| { + var j = i; + while (j > 0 and b.counts[j] > b.counts[j - 1]) : (j -= 1) { + std.mem.swap(c_int, &b.counts[j], &b.counts[j - 1]); + std.mem.swap([*c]u8, &b.froms[j], &b.froms[j - 1]); + std.mem.swap([*c]u8, &b.tos[j], &b.tos[j - 1]); + } + } + if (b.n > c.CBM_DECIMAL_BASE) { + for (b.froms[ST_MAX_ITERATIONS..b.n], b.tos[ST_MAX_ITERATIONS..b.n]) |from, to| { + c.free(from); + c.free(to); + } + b.n = ST_MAX_ITERATIONS; + } + + const result: [*c]c.cbm_cross_pkg_boundary_t = if (b.n > 0) interop.callocN(c.cbm_cross_pkg_boundary_t, b.n) else null; + for (0..b.n) |i| { + result[i] = .{ .from = b.froms[i], .to = b.tos[i], .call_count = b.counts[i] }; + } + out_arr.* = result; + out_count.* = @intCast(b.n); + return OK; +} + +const MAX_PREVIEW_NAMES = 15; + +/// Fallback: derive packages from QN segments when no Package nodes exist. +fn archPackagesFromQn(s: *Store, project: CStr, path: CStr, out_arr: *[*c]c.cbm_package_summary_t, out_count: *c_int) c_int { + const scope = ArchScope.init(path); + const qbase = "SELECT qualified_name FROM nodes WHERE project=?1 AND label IN " ++ + "('Function','Method','Class')"; + const stmt = scope.query(s.db, qbase, qbase ++ arch_path_scope_sql, project) orelse { + storeSetErrorSqlite(s, "arch_packages_qn"); + return ERR; + }; + + var pnames: [c.CBM_SZ_64][*c]u8 = undefined; + var pcounts: [c.CBM_SZ_64]c_int = undefined; + var np: usize = 0; + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + const pkg = cbm_qn_to_package(columnText(stmt, 0)); + if (pkg[0] == 0) continue; + const found = for (pnames[0..np], 0..) |name, i| { + if (streq(name, pkg)) break i; + } else null; + if (found) |i| { + pcounts[i] += 1; + } else if (np < pnames.len) { + pnames[np] = interop.heapStrdup(pkg); + pcounts[np] = 1; + np += 1; + } + } + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(stmt); + for (pnames[0..np]) |name| c.free(name); + return ERR; + } + _ = c.sqlite3_finalize(stmt); + + // Sort by count desc + for (1..@max(np, 1)) |i| { + var j = i; + while (j > 0 and pcounts[j] > pcounts[j - 1]) : (j -= 1) { + std.mem.swap(c_int, &pcounts[j], &pcounts[j - 1]); + std.mem.swap([*c]u8, &pnames[j], &pnames[j - 1]); + } + } + if (np > MAX_PREVIEW_NAMES) { + for (pnames[MAX_PREVIEW_NAMES..np]) |name| c.free(name); + np = MAX_PREVIEW_NAMES; + } + + const arr: [*c]c.cbm_package_summary_t = if (np > 0) interop.callocN(c.cbm_package_summary_t, np) else null; + for (0..np) |i| arr[i] = .{ .name = pnames[i], .node_count = pcounts[i] }; + out_arr.* = arr; + out_count.* = @intCast(np); + return OK; +} + +fn archPackages(s: *Store, project: CStr, path: CStr, out: *ArchInfo) c_int { + const scope = ArchScope.init(path); + const base = "SELECT n.name, COUNT(*) as cnt FROM nodes n " ++ + "WHERE n.project=?1 AND n.label='Package'"; + const stmt = scope.query( + s.db, + base ++ " GROUP BY n.name ORDER BY cnt DESC LIMIT 15", + base ++ " AND (n.file_path = ?2 OR n.file_path LIKE ?3) " ++ + "GROUP BY n.name ORDER BY cnt DESC LIMIT 15", + project, + ) orelse { + storeSetErrorSqlite(s, "arch_packages"); + return ERR; + }; + + var rows = Rows(c.cbm_package_summary_t).init(ST_INIT_CAP_16); + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + rows.add().* = .{ .name = columnDup(stmt, 0), .node_count = c.sqlite3_column_int(stmt, 1) }; + } + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(stmt); + out.packages = rows.items; + out.package_count = rows.len; + return ERR; + } + _ = c.sqlite3_finalize(stmt); + + // Fallback: group by QN segment if no Package nodes + var arr = rows.items; + var n = rows.len; + if (n == 0) { + c.free(arr); + const qn_rc = archPackagesFromQn(s, project, path, &arr, &n); + if (qn_rc != OK) return qn_rc; + } + + out.packages = arr; + out.package_count = n; + return OK; +} + +const Layer = struct { layer: CStr, reason: CStr }; + +/// Classifies a package by its fan-in/out; a computed reason is formatted +/// into reason_buf. +fn classifyLayer(in: c_int, out_deg: c_int, has_routes: bool, has_entry_points: bool, reason_buf: *[c.CBM_SZ_128]u8) Layer { + if (has_entry_points and out_deg > 0 and in == 0) { + return .{ .layer = "entry", .reason = "has entry points, only outbound calls" }; + } + if (has_routes) return .{ .layer = "api", .reason = "has HTTP route definitions" }; + if (in > out_deg and in > ST_MIN_INDEGREE) { + _ = interop.bprint(reason_buf, "high fan-in ({d} in, {d} out)", .{ in, out_deg }); + return .{ .layer = "core", .reason = reason_buf }; + } + if (out_deg == 0 and in > 0) return .{ .layer = "leaf", .reason = "only inbound calls, no outbound" }; + if (in == 0 and out_deg > 0) return .{ .layer = "entry", .reason = "only outbound calls" }; + _ = interop.bprint(reason_buf, "fan-in={d}, fan-out={d}", .{ in, out_deg }); + return .{ .layer = "internal", .reason = reason_buf }; +} + +/// Find or insert a package name, returning its index. Returns null if full. +fn findOrAddPkg(all_pkgs: [][*c]u8, npkgs: *usize, pkg: CStr) ?usize { + for (all_pkgs[0..npkgs.*], 0..) |name, j| { + if (streq(name, pkg)) return j; + } + if (npkgs.* < all_pkgs.len) { + const idx = npkgs.*; + all_pkgs[idx] = interop.heapStrdup(pkg); + npkgs.* += 1; + return idx; + } + return null; +} + +/// Check if a package name appears in an array. +fn pkgInList(pkg: CStr, list: []const [*c]u8) bool { + for (list) |name| { + if (streq(pkg, name)) return true; + } + return false; +} + +/// Collect package names from nodes matching a SQL query (must use ?1 = project). +/// Returns the names collected into pkgs; none when the query cannot be prepared. +fn collectPkgNames(s: *Store, comptime sql: [:0]const u8, project: CStr, path: CStr, pkgs: [][*c]u8) [][*c]u8 { + const scope = ArchScope.init(path); + const stmt = scope.query(s.db, sql, sql ++ arch_path_scope_sql, project) orelse return pkgs[0..0]; + var count: usize = 0; + while (c.sqlite3_step(stmt) == c.SQLITE_ROW and count < pkgs.len) { + pkgs[count] = interop.heapStrdup(cbm_qn_to_package(columnText(stmt, 0))); + count += 1; + } + _ = c.sqlite3_finalize(stmt); + return pkgs[0..count]; +} + +fn archLayers(s: *Store, project: CStr, path: CStr, out: *ArchInfo) c_int { + // Get boundaries for fan analysis + var boundaries_ptr: [*c]c.cbm_cross_pkg_boundary_t = null; + var bcount: c_int = 0; + const rc = archBoundaries(s, project, path, &boundaries_ptr, &bcount); + if (rc != OK) return rc; + const boundaries = span(boundaries_ptr, bcount); + + // Collect route and entry point packages + var route_buf: [c.CBM_SZ_32][*c]u8 = undefined; + const route_pkgs = collectPkgNames(s, "SELECT qualified_name FROM nodes WHERE project=?1 AND label='Route'", project, path, &route_buf); + + var entry_buf: [c.CBM_SZ_32][*c]u8 = undefined; + const entry_pkgs = collectPkgNames(s, "SELECT qualified_name FROM nodes WHERE project=?1 AND " ++ + "json_extract(properties, '$.is_entry_point') = 1", project, path, &entry_buf); + + // Compute fan-in/out per package + var all_pkgs: [ST_MAX_PKGS][*c]u8 = undefined; + var fan_in = std.mem.zeroes([ST_MAX_PKGS]c_int); + var fan_out = std.mem.zeroes([ST_MAX_PKGS]c_int); + var npkgs: usize = 0; + + for (boundaries) |*boundary| { + if (findOrAddPkg(&all_pkgs, &npkgs, boundary.from)) |fi| fan_out[fi] += boundary.call_count; + if (findOrAddPkg(&all_pkgs, &npkgs, boundary.to)) |ti| fan_in[ti] += boundary.call_count; + } + + // Also include route/entry packages + for (route_pkgs) |pkg| _ = findOrAddPkg(&all_pkgs, &npkgs, pkg); + for (entry_pkgs) |pkg| _ = findOrAddPkg(&all_pkgs, &npkgs, pkg); + + // Classify each package + out.layers = if (npkgs > 0) interop.callocN(c.cbm_package_layer_t, npkgs) else null; + out.layer_count = @intCast(npkgs); + for (0..npkgs) |i| { + const has_route = pkgInList(all_pkgs[i], route_pkgs); + const has_entry = pkgInList(all_pkgs[i], entry_pkgs); + var reason_buf: [c.CBM_SZ_128]u8 = undefined; + const layer = classifyLayer(fan_in[i], fan_out[i], has_route, has_entry, &reason_buf); + out.layers[i] = .{ + .name = all_pkgs[i], // transfer ownership + .layer = interop.heapStrdup(layer.layer), + .reason = interop.heapStrdup(layer.reason), + }; + } + + // Sort layers by name + const layers = span(out.layers, out.layer_count); + for (1..@max(layers.len, 1)) |i| { + var j = i; + while (j > 0 and c.strcmp(layers[j].name, layers[j - 1].name) < 0) : (j -= 1) { + std.mem.swap(c.cbm_package_layer_t, &layers[j], &layers[j - 1]); + } + } + + // Cleanup + for (boundaries) |*boundary| { + interop.strFree(&boundary.from); + interop.strFree(&boundary.to); + } + c.free(boundaries_ptr); + for (route_pkgs) |pkg| c.free(pkg); + for (entry_pkgs) |pkg| c.free(pkg); + + return OK; +} + +/// One directory of the file tree and the names of its direct children. +const ArchDir = struct { + path: [*c]u8, + children: [*c][*c]u8 = null, + child_count: c_int = 0, + child_cap: c_int = 0, + + /// Add a child to a dir entry if not already present. + fn addChild(dir: *ArchDir, child: []const u8) void { + for (span(dir.children, dir.child_count)) |existing| { + if (std.mem.eql(u8, std.mem.span(existing), child)) return; + } + if (dir.child_count >= dir.child_cap) { + dir.child_cap = if (dir.child_cap != 0) dir.child_cap * 2 else ST_INIT_CAP_4; + dir.children = interop.reallocN([*c]u8, dir.children, @intCast(dir.child_cap)); + } + dir.children[@intCast(dir.child_count)] = dupSlice(child); + dir.child_count += 1; + } +}; + +/// The directories of the file tree; at most CBM_SZ_64 are tracked. +const ArchDirs = struct { + items: [c.CBM_SZ_64]ArchDir = undefined, + len: usize = 0, + + fn slice(dirs: *ArchDirs) []ArchDir { + return dirs.items[0..dirs.len]; + } + + /// Find or create a directory entry by path. Null if full. + fn findOrCreate(dirs: *ArchDirs, path: []const u8) ?*ArchDir { + for (dirs.slice()) |*dir| { + if (std.mem.eql(u8, std.mem.span(dir.path), path)) return dir; + } + if (dirs.len < dirs.items.len) { + const dir = &dirs.items[dirs.len]; + dir.* = .{ .path = dupSlice(path) }; + dirs.len += 1; + return dir; + } + return null; + } +}; + +/// Create a file tree entry by checking if a path is a file and counting dir children. +fn makeTreeEntry(path: []const u8, files: []const [*c]u8, dirs: []const ArchDir) c.cbm_file_tree_entry_t { + var e: c.cbm_file_tree_entry_t = .{ .path = dupSlice(path) }; + const is_file = for (files) |file| { + if (std.mem.eql(u8, std.mem.span(file), path)) break true; + } else false; + e.type = interop.heapStrdup(if (is_file) "file" else "dir"); + for (dirs) |*dir| { + if (std.mem.eql(u8, std.mem.span(dir.path), path)) { + e.children = dir.child_count; + break; + } + } + return e; +} + +/// Split a path by '/' into parts. Returns number of parts; when parts is +/// full, the last part holds the rest of the path. +fn splitPathParts(fp: []const u8, parts: [][]const u8) usize { + var nparts: usize = 0; + var start: usize = 0; + for (fp, 0..) |ch, i| { + if (nparts + 1 >= parts.len) break; + if (ch == '/') { + parts[nparts] = fp[start..i]; + nparts += 1; + start = i + 1; + } + } + parts[nparts] = fp[start..]; + return nparts + 1; +} + +/// Register dir hierarchy for one file path. +fn archRegisterFileDirs(fp_c: CStr, dirs: *ArchDirs) void { + const full = std.mem.span(fp_c); + const fp = full[0..@min(full.len, c.CBM_SZ_512 - 1)]; + var parts_buf: [ST_SEARCH_MAX_BINDS][]const u8 = undefined; + const parts = parts_buf[0..splitPathParts(fp, &parts_buf)]; + + if (dirs.findOrCreate("")) |root| root.addChild(parts[0]); + + var depth: usize = 0; + while (depth + 1 < parts.len and depth < ST_MAX_PATH_DEPTH) : (depth += 1) { + // parts[0..depth] joined by '/' is the path up to the end of parts[depth]. + const dir_end = @intFromPtr(parts[depth].ptr) - @intFromPtr(fp.ptr) + parts[depth].len; + if (dirs.findOrCreate(fp[0..dir_end])) |dir| dir.addChild(parts[depth + 1]); + } +} + +/// Collect tree entries from dir arrays. +fn archCollectEntries(dirs: []const ArchDir, files: []const [*c]u8, entries_out: *[*c]c.cbm_file_tree_entry_t, en_out: *c_int) void { + var entries = Rows(c.cbm_file_tree_entry_t).init(c.CBM_SZ_64); + + // Root children + for (dirs) |*dir| { + if (dir.path[0] != 0) continue; + for (span(dir.children, dir.child_count)) |child| { + entries.add().* = makeTreeEntry(std.mem.span(child), files, dirs); + } + } + + // Non-root dir children (depth < 3) + for (dirs) |*dir| { + const dir_path = std.mem.span(dir.path); + if (dir_path.len == 0 or std.mem.count(u8, dir_path, "/") >= ST_MAX_PATH_DEPTH) continue; + for (span(dir.children, dir.child_count)) |child| { + var path: [c.CBM_SZ_512]u8 = undefined; + _ = interop.bprint(&path, "{s}/{s}", .{ dir_path, std.mem.span(child) }); + entries.add().* = makeTreeEntry(std.mem.sliceTo(&path, 0), files, dirs); + } + } + + // Sort by path + const sorted = entries.slice(); + for (1..@max(sorted.len, 1)) |i| { + var j = i; + while (j > 0 and c.strcmp(sorted[j].path, sorted[j - 1].path) < 0) : (j -= 1) { + std.mem.swap(c.cbm_file_tree_entry_t, &sorted[j], &sorted[j - 1]); + } + } + + entries_out.* = entries.items; + en_out.* = entries.len; +} + +/// Free dir arrays. +fn archFreeDirs(dirs: []const ArchDir, files: [*c][*c]u8, file_count: c_int) void { + for (dirs) |*dir| { + c.free(dir.path); + freeStrings(dir.children, dir.child_count); + } + freeStrings(files, file_count); +} + +fn archFileTree(s: *Store, project: CStr, path: CStr, out: *ArchInfo) c_int { + const scope = ArchScope.init(path); + const base = "SELECT file_path FROM nodes WHERE project=?1 AND label='File'"; + const stmt = scope.query(s.db, base, base ++ arch_path_scope_sql, project) orelse { + storeSetErrorSqlite(s, "arch_file_tree"); + return ERR; + }; + + var files = Rows([*c]u8).init(c.CBM_SZ_32); + var dirs: ArchDirs = .{}; + defer archFreeDirs(dirs.slice(), files.items, files.len); + + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + const fp = columnText(stmt, 0); + if (fp == null) continue; + files.add().* = interop.heapStrdup(fp); + archRegisterFileDirs(fp, &dirs); + } + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(stmt); + return ERR; + } + _ = c.sqlite3_finalize(stmt); + + archCollectEntries(dirs.slice(), files.slice(), &out.file_tree, &out.file_tree_count); + return OK; +} + +// ── Louvain community detection ───────────────────────────────── + +/// A malloc'd slice of n uninitialized T; null on OOM. +fn mallocSlice(comptime T: type, n: usize) ?[]T { + const items: ?[*]T = interop.mallocN(T, n); + return (items orelse return null)[0..n]; +} + +/// Find the index of a node ID in the nodes array. +fn louvainNodeIndex(nodes: []const i64, id: i64) ?u32 { + for (nodes, 0..) |node, i| { + if (node == id) return @intCast(i); + } + return null; +} + +/// Deduplicated, normalized (si < di) undirected edge weights. +const LouvainWeights = struct { + si: []u32, + di: []u32, + w: []f64, + n: usize = 0, + + fn deinit(weights: *const LouvainWeights) void { + c.free(weights.si.ptr); + c.free(weights.di.ptr); + c.free(weights.w.ptr); + } +}; + +/// Build deduplicated, normalized edge weight arrays from raw edges. Null on OOM. +fn louvainBuildWeights(nodes: []const i64, edges: []const c.cbm_louvain_edge_t) ?LouvainWeights { + const wcap = @max(edges.len, 1); + const wsi = mallocSlice(u32, wcap) orelse return null; + const wdi = mallocSlice(u32, wcap) orelse { + c.free(wsi.ptr); + return null; + }; + const ww = mallocSlice(f64, wcap) orelse { + c.free(wsi.ptr); + c.free(wdi.ptr); + return null; + }; + var weights: LouvainWeights = .{ .si = wsi, .di = wdi, .w = ww }; + + for (edges) |*edge| { + var si = louvainNodeIndex(nodes, edge.src) orelse continue; + var di = louvainNodeIndex(nodes, edge.dst) orelse continue; + if (si == di) continue; + if (si > di) std.mem.swap(u32, &si, &di); + const found = for (wsi[0..weights.n], wdi[0..weights.n], 0..) |a, b, i| { + if (a == si and b == di) break i; + } else null; + if (found) |i| { + ww[i] += 1.0; + } else { + wsi[weights.n] = si; + wdi[weights.n] = di; + ww[weights.n] = 1.0; + weights.n += 1; + } + } + return weights; +} + +const LEIDEN_MAX_LEVELS = 64; +const LEIDEN_MOVE_PASS_CAP = 100; +/// "No label yet" in the relabel map and the aggregate seeds. +const leiden_none = std.math.maxInt(u32); + +/// Weighted undirected graph in CSR form. Each undirected edge is stored as +/// two directed entries; k[i] is the weighted degree of node i. Self-loops are +/// never materialised — an aggregate node's intra-community weight is folded +/// into k[i] (which is preserved across levels), while nbr/w hold only +/// inter-community edges. The modularity gain therefore uses k[] for the +/// null-model term and nbr/w for connectivity, so intra weight affects the +/// degree but is never mistaken for an edge to another community. +const LeidenGraph = struct { + n: usize, + off: []u32, // CSR offsets, length n + 1 + nbr: []u32, // neighbour indices, length off[n] + w: []f64, // edge weights aligned with nbr + k: []f64, // weighted degree per node + + fn deinit(g: *const LeidenGraph) void { + c.free(g.off.ptr); + c.free(g.nbr.ptr); + c.free(g.w.ptr); + c.free(g.k.ptr); + } +}; + +const Oom = error{OutOfMemory}; + +/// Build a CSR graph from a deduplicated undirected edge list. +fn lgBuild(n: usize, wsi: []const u32, wdi: []const u32, ww: []const f64) Oom!LeidenGraph { + const off = callocSlice(u32, n + 1) orelse return error.OutOfMemory; + errdefer c.free(off.ptr); + const k = callocSlice(f64, n) orelse return error.OutOfMemory; + errdefer c.free(k.ptr); + const fill = mallocSlice(u32, n) orelse return error.OutOfMemory; + defer c.free(fill.ptr); + + for (wsi, wdi) |a, b| { + off[a + 1] += 1; + off[b + 1] += 1; + } + for (0..n) |i| off[i + 1] += off[i]; + const total = @max(off[n], 1); + const nbr = mallocSlice(u32, total) orelse return error.OutOfMemory; + errdefer c.free(nbr.ptr); + const w = mallocSlice(f64, total) orelse return error.OutOfMemory; + + @memcpy(fill, off[0..n]); + for (wsi, wdi, ww) |a, b, we| { + nbr[fill[a]] = b; + w[fill[a]] = we; + fill[a] += 1; + nbr[fill[b]] = a; + w[fill[b]] = we; + fill[b] += 1; + k[a] += we; + k[b] += we; + } + return .{ .n = n, .off = off, .nbr = nbr, .w = w, .k = k }; +} + +/// Local-moving phase: greedily move each node to the neighbouring community +/// with the highest modularity gain, using a work queue seeded with every node +/// and re-queueing only the neighbours of a node that actually moved. Mutates +/// comm[] in place. +fn leidenMove(g: *const LeidenGraph, comm: []u32, gamma: f64, twom: f64) void { + const n = g.n; + const stot = callocSlice(f64, n) orelse return; + defer c.free(stot.ptr); + const acc = callocSlice(f64, n) orelse return; + defer c.free(acc.ptr); + const queue = mallocSlice(u32, n) orelse return; + defer c.free(queue.ptr); + const dirty = mallocSlice(u32, n) orelse return; + defer c.free(dirty.ptr); + const inq = callocSlice(bool, n) orelse return; + defer c.free(inq.ptr); + + for (0..n) |i| { + stot[comm[i]] += g.k[i]; + queue[i] = @intCast(i); + inq[i] = true; + } + var qhead: usize = 0; + var qcount: usize = n; + var cap: i64 = @as(i64, @intCast(n)) * LEIDEN_MOVE_PASS_CAP + LEIDEN_MAX_LEVELS; + while (qcount > 0 and cap > 0) : (cap -= 1) { + const v = queue[qhead]; + qhead = (qhead + 1) % n; + qcount -= 1; + inq[v] = false; + const cv = comm[v]; + const lo = g.off[v]; + const hi = g.off[v + 1]; + var ndirty: usize = 0; + for (g.nbr[lo..hi], g.w[lo..hi]) |u, weight| { + if (u == v) continue; + const cu = comm[u]; + if (acc[cu] == 0.0) { + dirty[ndirty] = cu; + ndirty += 1; + } + acc[cu] += weight; + } + stot[cv] -= g.k[v]; + const kv = g.k[v]; + var best_c = cv; + var best_gain = acc[cv] - gamma * kv * stot[cv] / twom; + for (dirty[0..ndirty]) |cand| { + const gain = acc[cand] - gamma * kv * stot[cand] / twom; + if (gain > best_gain) { + best_gain = gain; + best_c = cand; + } + } + stot[best_c] += kv; + comm[v] = best_c; + if (best_c != cv) { + for (g.nbr[lo..hi]) |u| { + if (comm[u] != best_c and !inq[u] and qcount < n) { + queue[(qhead + qcount) % n] = u; + qcount += 1; + inq[u] = true; + } + } + } + for (dirty[0..ndirty]) |cand| acc[cand] = 0.0; + } +} + +/// Compact community labels in comm[] to the dense range [0, returned count). +fn leidenRelabel(comm: []u32) usize { + const map = mallocSlice(u32, comm.len) orelse return comm.len; + defer c.free(map.ptr); + @memset(map, leiden_none); + var next: u32 = 0; + for (comm) |*label| { + if (map[label.*] == leiden_none) { + map[label.*] = next; + next += 1; + } + label.* = map[label.*]; + } + return next; +} + +/// Refinement phase: within each move-phase community, merge singleton nodes +/// into the best connected sub-community (positive modularity gain, edge must +/// exist). This re-derives communities bottom-up so each one is guaranteed +/// internally connected — the defect single-level Louvain suffers from, which +/// fragments the graph into hundreds of tiny noisy clusters. Writes +/// sub-community labels into refined[] and returns their count. +fn leidenRefine(g: *const LeidenGraph, comm: []const u32, gamma: f64, twom: f64, refined: []u32) usize { + const n = g.n; + for (refined, 0..) |*label, i| label.* = @intCast(i); + const stot = callocSlice(f64, n) orelse return leidenRelabel(refined); + defer c.free(stot.ptr); + const acc = callocSlice(f64, n) orelse return leidenRelabel(refined); + defer c.free(acc.ptr); + const rsize = mallocSlice(u32, n) orelse return leidenRelabel(refined); + defer c.free(rsize.ptr); + const dirty = mallocSlice(u32, n) orelse return leidenRelabel(refined); + defer c.free(dirty.ptr); + + @memcpy(stot, g.k[0..n]); + @memset(rsize, 1); + for (0..n) |v| { + if (rsize[refined[v]] != 1) continue; // only singletons merge, per the refinement rule + const cv = comm[v]; + const lo = g.off[v]; + const hi = g.off[v + 1]; + var ndirty: usize = 0; + for (g.nbr[lo..hi], g.w[lo..hi]) |u, weight| { + if (u == v or comm[u] != cv) continue; // stay within the move-phase community + const ru = refined[u]; + if (acc[ru] == 0.0) { + dirty[ndirty] = ru; + ndirty += 1; + } + acc[ru] += weight; + } + const rv = refined[v]; + const kv = g.k[v]; + stot[rv] -= kv; + var best_r = rv; + var best_gain: f64 = 0.0; + for (dirty[0..ndirty]) |r| { + if (r == rv) continue; + const gain = acc[r] - gamma * kv * stot[r] / twom; + if (gain > best_gain) { + best_gain = gain; + best_r = r; + } + } + if (best_r != rv) { + refined[v] = best_r; + stot[best_r] += kv; + rsize[best_r] += 1; + rsize[rv] -= 1; + } else { + stot[rv] += kv; + } + for (dirty[0..ndirty]) |r| acc[r] = 0.0; + } + return leidenRelabel(refined); +} + +/// Aggregation phase: collapse each refined sub-community into a single node. +/// Returns the coarser graph and records, for each aggregate node, the +/// move-phase community it belonged to in seed[] so the next level starts from +/// the coarse structure rather than from singletons. +fn leidenAggregate(g: *const LeidenGraph, refined: []const u32, r_count: usize, comm: []const u32, seed: []u32) Oom!LeidenGraph { + const n = g.n; + const k2 = callocSlice(f64, r_count) orelse return error.OutOfMemory; + errdefer c.free(k2.ptr); + const gcount = callocSlice(u32, r_count) orelse return error.OutOfMemory; + defer c.free(gcount.ptr); + const gstart = mallocSlice(u32, r_count + 1) orelse return error.OutOfMemory; + defer c.free(gstart.ptr); + const members = mallocSlice(u32, n) orelse return error.OutOfMemory; + defer c.free(members.ptr); + const fill = mallocSlice(u32, r_count) orelse return error.OutOfMemory; + defer c.free(fill.ptr); + const acc = callocSlice(f64, r_count) orelse return error.OutOfMemory; + defer c.free(acc.ptr); + const dirty = mallocSlice(u32, r_count) orelse return error.OutOfMemory; + defer c.free(dirty.ptr); + const off2 = mallocSlice(u32, r_count + 1) orelse return error.OutOfMemory; + errdefer c.free(off2.ptr); + + @memset(seed[0..r_count], leiden_none); + for (0..n) |i| { + const r = refined[i]; + k2[r] += g.k[i]; + gcount[r] += 1; + if (seed[r] == leiden_none) seed[r] = comm[i]; + } + gstart[0] = 0; + for (0..r_count) |r| { + gstart[r + 1] = gstart[r] + gcount[r]; + fill[r] = gstart[r]; + } + for (0..n) |i| { + members[fill[refined[i]]] = @intCast(i); + fill[refined[i]] += 1; + } + // Pass 1: count distinct inter-community neighbours per aggregate node. + off2[0] = 0; + for (0..r_count) |r| { + var nd: u32 = 0; + for (members[gstart[r]..gstart[r + 1]]) |i| { + for (g.nbr[g.off[i]..g.off[i + 1]]) |neighbor| { + const rb = refined[neighbor]; + if (rb == r or acc[rb] != 0.0) continue; + acc[rb] = 1.0; + dirty[nd] = rb; + nd += 1; + } + } + off2[r + 1] = off2[r] + nd; + for (dirty[0..nd]) |rb| acc[rb] = 0.0; + } + const total = @max(off2[r_count], 1); + const nbr2 = mallocSlice(u32, total) orelse return error.OutOfMemory; + errdefer c.free(nbr2.ptr); + const w2 = mallocSlice(f64, total) orelse return error.OutOfMemory; + // Pass 2: accumulate inter-community edge weights. + for (0..r_count) |r| { + var nd: usize = 0; + for (members[gstart[r]..gstart[r + 1]]) |i| { + const lo = g.off[i]; + const hi = g.off[i + 1]; + for (g.nbr[lo..hi], g.w[lo..hi]) |neighbor, weight| { + const rb = refined[neighbor]; + if (rb == r) continue; + if (acc[rb] == 0.0) { + dirty[nd] = rb; + nd += 1; + } + acc[rb] += weight; + } + } + const base = off2[r]; + for (dirty[0..nd], 0..) |rb, d| { + nbr2[base + d] = rb; + w2[base + d] = acc[rb]; + acc[rb] = 0.0; + } + } + return .{ .n = r_count, .off = off2, .nbr = nbr2, .w = w2, .k = k2 }; +} + +pub export fn cbm_leiden( + nodes_c: [*c]const i64, + node_count: c_int, + edges_c: [*c]const c.cbm_louvain_edge_t, + edge_count: c_int, + resolution: f64, + out: *[*c]c.cbm_louvain_result_t, + out_count: *c_int, +) c_int { + if (node_count <= 0) { + out.* = null; + out_count.* = 0; + return OK; + } + const n: usize = @intCast(node_count); + const nodes = nodes_c[0..n]; + const gamma = if (resolution > 0.0) resolution else 1.0; + + const result = mallocSlice(c.cbm_louvain_result_t, n) orelse return c.CBM_NOT_FOUND; + for (result, nodes, 0..) |*row, node, i| row.* = .{ .node_id = node, .community = @intCast(i) }; + // Every return below hands back result; until the levels run, every node + // is its own community. + out.* = result.ptr; + out_count.* = node_count; + + // Build deduplicated undirected edge weights, then a CSR graph. + const weights = louvainBuildWeights(nodes, span(edges_c, edge_count)) orelse return OK; + const built: ?LeidenGraph = if (weights.n > 0) + lgBuild(n, weights.si[0..weights.n], weights.di[0..weights.n], weights.w[0..weights.n]) catch null + else + null; + weights.deinit(); + // No edges (or allocation failure): every node is its own community. + var g = built orelse return OK; + defer g.deinit(); + + var twom: f64 = 0.0; + for (g.k) |degree| twom += degree; + if (twom <= 0.0) return OK; + + const orig = mallocSlice(u32, n) orelse return OK; // original node -> current graph node + defer c.free(orig.ptr); + var comm = mallocSlice(u32, n) orelse return OK; + defer c.free(comm.ptr); + for (orig, comm, 0..) |*o, *label, i| { + o.* = @intCast(i); + label.* = @intCast(i); + } + + for (0..LEIDEN_MAX_LEVELS) |_| { + leidenMove(&g, comm, gamma, twom); + const c_count = leidenRelabel(comm); + if (c_count >= g.n) break; // every node already isolated — nothing to coarsen + const refined = mallocSlice(u32, g.n) orelse break; + defer c.free(refined.ptr); + const r_count = leidenRefine(&g, comm, gamma, twom, refined); + if (r_count >= g.n) break; // refinement cannot reduce the graph further + for (orig) |*o| o.* = refined[o.*]; + const seed = mallocSlice(u32, r_count) orelse break; + const g2 = leidenAggregate(&g, refined, r_count, comm, seed) catch { + c.free(seed.ptr); + break; + }; + g.deinit(); + g = g2; + c.free(comm.ptr); + comm = seed; + } + + for (result, orig) |*row, o| row.community = @intCast(comm[o]); + return OK; +} + +pub export fn cbm_louvain( + nodes: [*c]const i64, + node_count: c_int, + edges: [*c]const c.cbm_louvain_edge_t, + edge_count: c_int, + out: *[*c]c.cbm_louvain_result_t, + out_count: *c_int, +) c_int { + return cbm_leiden(nodes, node_count, edges, edge_count, 1.0, out, out_count); +} + +// ── Architecture: community clusters via Leiden ───────────────── + +const CBM_CLUSTER_TOP_N = 12; // report at most this many clusters +const CBM_CLUSTER_MAX_TOPNODES = 5; // representative node names per cluster +const CBM_CLUSTER_MAX_PKGS = 5; // packages listed per cluster +const CBM_CLUSTER_MIN_MEMBERS = 2; // skip singletons +const CBM_CLUSTER_NODE_CAP = 8000; // bound the work for very large graphs + +/// Index of node id within the id-sorted ids[]. +fn clusterIdIndex(ids: []const i64, id: i64) ?u32 { + var lo: usize = 0; + var hi: usize = ids.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + if (ids[mid] == id) return @intCast(mid); + if (ids[mid] < id) { + lo = mid + 1; + } else { + hi = mid; + } + } + return null; +} + +/// A distinct package list (with a per-package count). +const ClusterPkgs = struct { + names: [CBM_CLUSTER_MAX_PKGS][*c]u8 = undefined, + counts: [CBM_CLUSTER_MAX_PKGS]c_int = undefined, + len: usize = 0, + + /// Append `pkg` to the list, or count it when already present. + fn add(pkgs: *ClusterPkgs, pkg: CStr) void { + if (pkg == null or pkg[0] == 0) return; + for (pkgs.names[0..pkgs.len], pkgs.counts[0..pkgs.len]) |name, *count| { + if (streq(name, pkg)) { + count.* += 1; + return; + } + } + if (pkgs.len < pkgs.names.len) { + pkgs.names[pkgs.len] = interop.heapStrdup(pkg); + pkgs.counts[pkgs.len] = 1; + pkgs.len += 1; + } + } +}; + +/// Build the cluster_info for one community into ci. +fn clusterBuildOne( + ci: *c.cbm_cluster_info_t, + community: u32, + comm: []const u32, + degree: []const c_int, + names: []const [*c]u8, + qns: []const [*c]u8, + members: c_int, + cohesion: f64, +) void { + ci.* = .{ .id = @intCast(community), .members = members, .cohesion = cohesion }; + + // Top nodes by degree. + var top_idx: [CBM_CLUSTER_MAX_TOPNODES]usize = undefined; + var top_deg: [CBM_CLUSTER_MAX_TOPNODES]c_int = undefined; + var tn: usize = 0; + for (comm, degree, 0..) |label, d, i| { + if (label != community) continue; + var pos = tn; + while (pos > 0 and top_deg[pos - 1] < d) pos -= 1; + if (pos < CBM_CLUSTER_MAX_TOPNODES) { + var k = if (tn < CBM_CLUSTER_MAX_TOPNODES) tn else CBM_CLUSTER_MAX_TOPNODES - 1; + while (k > pos) : (k -= 1) { + top_idx[k] = top_idx[k - 1]; + top_deg[k] = top_deg[k - 1]; + } + top_idx[pos] = i; + top_deg[pos] = d; + if (tn < CBM_CLUSTER_MAX_TOPNODES) tn += 1; + } + } + if (tn > 0) { + if (mallocSlice(CStr, tn)) |top_nodes| { + for (top_nodes, top_idx[0..tn]) |*top, idx| top.* = interop.heapStrdup(names[idx]); + ci.top_nodes = top_nodes.ptr; + ci.top_node_count = @intCast(tn); + } + } + + // Distinct packages (+ dominant one as the label). + var pkgs: ClusterPkgs = .{}; + for (comm, qns) |label, qn| { + if (label == community) pkgs.add(cbm_qn_to_top_package(qn)); + } + if (pkgs.len > 0) { + var best: usize = 0; + for (pkgs.counts[0..pkgs.len], 0..) |count, i| { + if (count > pkgs.counts[best]) best = i; + } + ci.label = interop.heapStrdup(pkgs.names[best]); + // The package names move into the cluster. + if (mallocSlice(CStr, pkgs.len)) |packages| { + for (packages, pkgs.names[0..pkgs.len]) |*package, name| package.* = name; + ci.packages = packages.ptr; + ci.package_count = @intCast(pkgs.len); + } else { + for (pkgs.names[0..pkgs.len]) |name| c.free(name); + } + } + if (ci.label == null) { + ci.label = interop.heapStrdup(if (ci.top_node_count > 0) ci.top_nodes[0] else "cluster"); + } + + if (mallocSlice(CStr, 1)) |edge_types| { + edge_types[0] = interop.heapStrdup("CALLS"); + ci.edge_types = edge_types.ptr; + ci.edge_type_count = 1; + } +} + +/// Comparator for sorting community indices by descending member count. +const ClusterRank = extern struct { + comm: c_int, + members: c_int, +}; + +fn clusterRankCmp(a: ?*const anyopaque, b: ?*const anyopaque) callconv(.c) c_int { + const ca: *const ClusterRank = @ptrCast(@alignCast(a)); + const cb: *const ClusterRank = @ptrCast(@alignCast(b)); + return cb.members - ca.members; +} + +fn archClusters(s: *Store, project: CStr, path: CStr, out: *ArchInfo) c_int { + const scope = ArchScope.init(path); + const nbase = "SELECT id, name, qualified_name, file_path FROM nodes " ++ + "WHERE project=?1 AND label IN ('Function','Method','Class')"; + const st = scope.query( + s.db, + nbase ++ " ORDER BY id LIMIT ?2", + nbase ++ arch_path_scope_sql ++ " ORDER BY id LIMIT ?4", + project, + ) orelse return OK; // clusters are best-effort + _ = c.sqlite3_bind_int(st, if (scope.scoped) 4 else 2, CBM_CLUSTER_NODE_CAP); + + var ids = Rows(i64).init(ST_INIT_CAP_8); + var names = Rows([*c]u8).init(ST_INIT_CAP_8); + var qns = Rows([*c]u8).init(ST_INIT_CAP_8); + defer { + freeStrings(names.items, names.len); + freeStrings(qns.items, qns.len); + c.free(ids.items); + } + var rc = c.sqlite3_step(st); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(st)) { + ids.add().* = c.sqlite3_column_int64(st, 0); + names.add().* = columnDup(st, 1); + qns.add().* = columnDup(st, 2); + } + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(st); + return ERR; + } + _ = c.sqlite3_finalize(st); + const n: usize = @intCast(ids.len); + if (n < CBM_CLUSTER_MIN_MEMBERS) return OK; + + // 2. Load CALLS edges with both endpoints in the node set (store indices). + var edges = Rows(c.cbm_louvain_edge_t).init(ids.len); + var esrc = Rows(u32).init(ids.len); + var edst = Rows(u32).init(ids.len); + defer { + c.free(edges.items); + c.free(esrc.items); + c.free(edst.items); + } + const degree = callocSlice(c_int, n) orelse return OK; + defer c.free(degree.ptr); + var calls: [*c]StoreIdPair = null; + var call_count: c_int = 0; + if (storeCallsPairs(s, project, &calls, &call_count) == OK) { + for (span(calls, call_count)) |*call| { + const si = clusterIdIndex(ids.slice(), call.source_id) orelse continue; + const ti = clusterIdIndex(ids.slice(), call.target_id) orelse continue; + if (si == ti) continue; + edges.add().* = .{ .src = ids.slice()[si], .dst = ids.slice()[ti] }; + esrc.add().* = si; + edst.add().* = ti; + degree[si] += 1; + degree[ti] += 1; + } + c.free(calls); + } + + // 3. Community detection. + var res: [*c]c.cbm_louvain_result_t = null; + var rn: c_int = 0; + const leiden_rc = cbm_leiden(ids.items, ids.len, edges.items, edges.len, 1.0, &res, &rn); + defer c.free(res); + if (leiden_rc != OK or res == null or rn != ids.len) return OK; + const comm = mallocSlice(u32, n) orelse return OK; + defer c.free(comm.ptr); + var community_count: usize = 0; + for (comm, res[0..n]) |*label, *row| { + label.* = @intCast(row.community); + community_count = @max(community_count, @as(usize, label.*) + 1); + } + + // 4. Members + cohesion (internal vs boundary edges) per community. + const members = callocSlice(c_int, community_count) orelse return OK; + defer c.free(members.ptr); + const internal = callocSlice(c_int, community_count) orelse return OK; + defer c.free(internal.ptr); + const boundary = callocSlice(c_int, community_count) orelse return OK; + defer c.free(boundary.ptr); + for (comm) |label| members[label] += 1; + for (esrc.slice(), edst.slice()) |si, ti| { + const cs = comm[si]; + const cd = comm[ti]; + if (cs == cd) { + internal[cs] += 1; + } else { + boundary[cs] += 1; + boundary[cd] += 1; + } + } + + // 5. Rank communities by size, take the top N non-singletons. + const rank = mallocSlice(ClusterRank, community_count) orelse return OK; + defer c.free(rank.ptr); + for (rank, members, 0..) |*entry, member_count, i| entry.* = .{ .comm = @intCast(i), .members = member_count }; + if (community_count > 1) c.qsort(rank.ptr, community_count, @sizeOf(ClusterRank), clusterRankCmp); + + const clusters = mallocSlice(c.cbm_cluster_info_t, CBM_CLUSTER_TOP_N) orelse return OK; + var cc: usize = 0; + for (rank) |*entry| { + if (cc >= CBM_CLUSTER_TOP_N) break; + const community: u32 = @intCast(entry.comm); + if (members[community] < CBM_CLUSTER_MIN_MEMBERS) break; // sorted desc — the rest are singletons too + const denom: f64 = @floatFromInt(internal[community] + boundary[community]); + const cohesion = if (denom > 0) @as(f64, @floatFromInt(internal[community])) / denom else 0.0; + clusterBuildOne(&clusters[cc], community, comm, degree, names.slice(), qns.slice(), members[community], cohesion); + cc += 1; + } + out.clusters = clusters.ptr; + out.cluster_count = @intCast(cc); + return OK; +} + +// ── GetArchitecture dispatch ──────────────────────────────────── + +/// "overview" = compact architecture summary: every aspect EXCEPT the large +/// per-file listing (file_tree), which alone dominates the payload on real +/// repos and can push the MCP response past the output cap. Declared in +/// store.h and shared with aspect_wanted in src/mcp/mcp.zig so the store-side +/// DB gate and the MCP-side serialization gate cannot drift. +pub export fn cbm_store_arch_aspect_in_overview(name: CStr) bool { + return !streq(name, "file_tree"); +} + +fn wantAspect(aspects: [*c]const CStr, aspect_count: c_int, name: CStr) bool { + if (aspects == null or aspect_count == 0) return true; + for (span(aspects, aspect_count)) |aspect| { + if (streq(aspect, "all")) return true; + if (streq(aspect, "overview") and cbm_store_arch_aspect_in_overview(name)) return true; + if (streq(aspect, name)) return true; + } + return false; +} + +pub export fn cbm_store_get_architecture( + s_c: ?*Store, + project_c: CStr, + path: CStr, + aspects: [*c]CStr, + aspect_count: c_int, + out: *ArchInfo, +) c_int { + out.* = std.mem.zeroes(ArchInfo); + var project = project_c; + var rc = storeLegacyScope(s_c, &project); + if (rc != OK) return rc; + const s = s_c orelse return ERR; + + if (wantAspect(aspects, aspect_count, "languages")) { + rc = archLanguages(s, project, path, out); + if (rc != OK) return rc; + } + if (wantAspect(aspects, aspect_count, "packages")) { + rc = archPackages(s, project, path, out); + if (rc != OK) return rc; + } + if (wantAspect(aspects, aspect_count, "entry_points")) { + rc = archEntryPoints(s, project, path, out); + if (rc != OK) return rc; + } + if (wantAspect(aspects, aspect_count, "routes")) { + rc = archRoutes(s, project, path, out); + if (rc != OK) return rc; + } + if (wantAspect(aspects, aspect_count, "hotspots")) { + rc = archHotspots(s, project, path, out); + if (rc != OK) return rc; + } + if (wantAspect(aspects, aspect_count, "boundaries")) { + rc = archBoundaries(s, project, path, &out.boundaries, &out.boundary_count); + if (rc != OK) return rc; + } + if (wantAspect(aspects, aspect_count, "layers")) { + rc = archLayers(s, project, path, out); + if (rc != OK) return rc; + } + if (wantAspect(aspects, aspect_count, "file_tree")) { + rc = archFileTree(s, project, path, out); + if (rc != OK) return rc; + } + if (wantAspect(aspects, aspect_count, "clusters")) { + rc = archClusters(s, project, path, out); + if (rc != OK) return rc; + } + + return OK; +} + +pub export fn cbm_store_architecture_free(out_c: ?*ArchInfo) void { + const out = out_c orelse return; + for (span(out.languages, out.language_count)) |*language| interop.strFree(&language.language); + c.free(out.languages); + for (span(out.packages, out.package_count)) |*package| interop.strFree(&package.name); + c.free(out.packages); + for (span(out.entry_points, out.entry_point_count)) |*entry| { + interop.strFree(&entry.name); + interop.strFree(&entry.qualified_name); + interop.strFree(&entry.file); + } + c.free(out.entry_points); + for (span(out.routes, out.route_count)) |*route| { + interop.strFree(&route.method); + interop.strFree(&route.path); + interop.strFree(&route.handler); + } + c.free(out.routes); + for (span(out.hotspots, out.hotspot_count)) |*hotspot| { + interop.strFree(&hotspot.name); + interop.strFree(&hotspot.qualified_name); + } + c.free(out.hotspots); + for (span(out.boundaries, out.boundary_count)) |*boundary| { + interop.strFree(&boundary.from); + interop.strFree(&boundary.to); + } + c.free(out.boundaries); + for (span(out.services, out.service_count)) |*service| { + interop.strFree(&service.from); + interop.strFree(&service.to); + interop.strFree(&service.type); + } + c.free(out.services); + for (span(out.layers, out.layer_count)) |*layer| { + interop.strFree(&layer.name); + interop.strFree(&layer.layer); + interop.strFree(&layer.reason); + } + c.free(out.layers); + for (span(out.clusters, out.cluster_count)) |*cluster| { + interop.strFree(&cluster.label); + freeConstStrings(cluster.top_nodes, cluster.top_node_count); + freeConstStrings(cluster.packages, cluster.package_count); + freeConstStrings(cluster.edge_types, cluster.edge_type_count); + } + c.free(out.clusters); + for (span(out.file_tree, out.file_tree_count)) |*entry| { + interop.strFree(&entry.path); + interop.strFree(&entry.type); + } + c.free(out.file_tree); + out.* = std.mem.zeroes(ArchInfo); +} + +// ── Project summaries ─────────────────────────────────────────── + +pub export fn cbm_store_project_summary_export(s_c: ?*Store, project: CStr, out_c: ?*c.cbm_project_summary_export_t) c_int { + const s = s_c orelse return ERR; + const out = out_c orelse return ERR; + if (project == null) return ERR; + out.* = .{}; + const stmt = prepareOne(s.db, "SELECT summary, source_hash, created_at, updated_at " ++ + "FROM project_summaries WHERE project=?1") orelse { + storeSetErrorSqlite(s, "project_summary_export_prepare"); + return ERR; + }; + _ = bindText(stmt, 1, project); + const step = c.sqlite3_step(stmt); + if (step == c.SQLITE_DONE) { + _ = c.sqlite3_finalize(stmt); + return NOT_FOUND; + } + if (step != c.SQLITE_ROW) { + _ = c.sqlite3_finalize(stmt); + storeSetErrorSqlite(s, "project_summary_export_step"); + return ERR; + } + out.* = .{ + .summary = columnDup(stmt, 0), + .source_hash = columnDup(stmt, 1), + .created_at = columnDup(stmt, 2), + .updated_at = columnDup(stmt, 3), + }; + _ = c.sqlite3_finalize(stmt); + if (out.summary == null or out.source_hash == null or out.created_at == null or out.updated_at == null) { + cbm_store_project_summary_export_free(out); + storeSetError(s, "project summary export allocation failed"); + return ERR; + } + return OK; +} + +pub export fn cbm_store_project_summary_export_free(row_c: ?*c.cbm_project_summary_export_t) void { + const row = row_c orelse return; + c.free(row.summary); + c.free(row.source_hash); + c.free(row.created_at); + c.free(row.updated_at); + row.* = .{}; +} + +// ── Architecture doc discovery ────────────────────────────────── + +pub export fn cbm_store_find_architecture_docs(s_c: ?*Store, project: CStr, out: *[*c][*c]u8, count: *c_int) c_int { + const s = s_c orelse return ERR; + const stmt = prepareOne(s.db, "SELECT file_path FROM nodes WHERE project=?1 AND label='File' " ++ + "AND (file_path LIKE '%ARCHITECTURE.md' OR file_path LIKE '%ADR.md' " ++ + "OR file_path LIKE '%DECISIONS.md' OR file_path LIKE 'docs/adr/%' " ++ + "OR file_path LIKE 'doc/adr/%' OR file_path LIKE 'adr/%') " ++ + "ORDER BY file_path LIMIT 20") orelse { + storeSetErrorSqlite(s, "find_arch_docs"); + return ERR; + }; + _ = bindText(stmt, 1, project); + + var arr = Rows([*c]u8).init(ST_INIT_CAP_8); + var rc = c.sqlite3_step(stmt); + while (rc == c.SQLITE_ROW) : (rc = c.sqlite3_step(stmt)) { + arr.add().* = columnDup(stmt, 0); + } + if (rc != c.SQLITE_DONE) { + storeSetErrorSqlite(s, "row scan aborted"); + _ = c.sqlite3_finalize(stmt); + freeStrings(arr.items, arr.len); + out.* = null; + count.* = 0; + return ERR; + } + _ = c.sqlite3_finalize(stmt); + out.* = arr.items; + count.* = arr.len; + return OK; +} + +// ── Memory management ─────────────────────────────────────────── + +pub export fn cbm_node_free_fields(n: *c.cbm_node_t) void { + interop.strFree(&n.project); + interop.strFree(&n.label); + interop.strFree(&n.name); + interop.strFree(&n.qualified_name); + interop.strFree(&n.file_path); + interop.strFree(&n.properties_json); +} + +pub export fn cbm_store_free_nodes(nodes: [*c]c.cbm_node_t, count: c_int) void { + if (nodes == null) return; + for (span(nodes, count)) |*node| cbm_node_free_fields(node); + c.free(nodes); +} + +pub export fn cbm_store_free_edges(edges: [*c]c.cbm_edge_t, count: c_int) void { + if (edges == null) return; + for (span(edges, count)) |*edge| { + interop.strFree(&edge.project); + interop.strFree(&edge.type); + interop.strFree(&edge.properties_json); + } + c.free(edges); +} + +pub export fn cbm_project_free_fields(p: *c.cbm_project_t) void { + interop.strFree(&p.name); + interop.strFree(&p.indexed_at); + interop.strFree(&p.root_path); +} + +pub export fn cbm_store_free_projects(projects: [*c]c.cbm_project_t, count: c_int) void { + if (projects == null) return; + for (span(projects, count)) |*project| cbm_project_free_fields(project); + c.free(projects); +} + +pub export fn cbm_store_free_file_hashes(hashes: [*c]c.cbm_file_hash_t, count: c_int) void { + if (hashes == null) return; + for (span(hashes, count)) |*hash| { + interop.strFree(&hash.project); + interop.strFree(&hash.rel_path); + interop.strFree(&hash.sha256); + } + c.free(hashes); +} + +// ── Vector search ─────────────────────────────────────────────── + +const VectorResult = c.cbm_vector_result_t; +const VectorMetrics = c.cbm_vector_search_metrics_t; +const VectorSession = c.cbm_zova_vector_session_t; + +pub export fn cbm_store_count_vectors(s_c: ?*Store, project: CStr) c_int { + const s = s_c orelse return 0; + if (project == null) return 0; + const stmt = prepareOne(s.db, "SELECT count(*) FROM node_vectors WHERE project = ?1") orelse return 0; + _ = c.sqlite3_bind_text(stmt, 1, project, -1, null); + var count: c_int = 0; + if (c.sqlite3_step(stmt) == c.SQLITE_ROW) count = c.sqlite3_column_int(stmt, 0); + _ = c.sqlite3_finalize(stmt); + return count; +} + +fn vectorResultFreeFields(result: *const VectorResult) void { + c.free(result.project); + c.free(result.name); + c.free(result.qualified_name); + c.free(result.file_path); + c.free(result.label); +} + +pub export fn cbm_store_free_vector_results(results: [*c]VectorResult, count: c_int) void { + if (results == null) return; + for (span(results, count)) |*result| vectorResultFreeFields(result); + c.free(results); +} + +// Per-keyword scoring: score each keyword independently against each node +// vector, then combine using min(cosine_k) across keywords. This ensures +// ALL keywords must be relevant, not just the average. +const VS_VEC_DIM = 768; +const VS_SPARSE_NNZE = 8; +const VS_RI_SEED = 0x52494E44; +const VS_MAX_KW = 32; +const VS_STR_BUF = 16; + +/// One int8-quantized query or node vector. +const KwVec = [VS_VEC_DIM]i8; +const FloatVec = [VS_VEC_DIM]f32; + +/// De-quantizes an int8 vector into out. +fn vsDequantize(values: *const KwVec, out: *FloatVec) void { + for (out, values) |*value, quantized| value.* = @as(f32, @floatFromInt(quantized)) / CBM_STORE_INT8_MAX; +} + +fn vsWorkspaceVectorState(s_c: ?*Store, project: CStr, model: []u8, out_dimensions: *c_int, out_generation: *i64) c_int { + const s = s_c orelse return ERR; + if (!s.zova_workspace_query or project == null or model.len == 0) return ERR; + const stmt = prepareOne(s.db, "SELECT s.model_fingerprint,s.vector_dimensions,s.generation " ++ + "FROM cbm_workspace_index_state_v1 s INNER JOIN cbm_projects_v1 p " ++ + "ON p.workspace_key=s.workspace_key JOIN cbm_workspace_registry r " ++ + "ON r.workspace_key=s.workspace_key WHERE r.workspace_id=?1 AND p.project=?2 " ++ + "AND s.generation>0") orelse return ERR; + _ = bindText(stmt, 1, &s.zova_graph_workspace_id); + _ = bindText(stmt, 2, project); + var rc: c_int = NOT_FOUND; + if (c.sqlite3_step(stmt) == c.SQLITE_ROW) { + const stored_model = columnText(stmt, 0); + const dimensions = c.sqlite3_column_int(stmt, 1); + const generation = c.sqlite3_column_int64(stmt, 2); + if (stored_model != null and stored_model[0] != 0 and dimensions > 0 and generation > 0) { + const text = std.mem.span(stored_model); + if (text.len < model.len) { + @memcpy(model[0..text.len], text); + model[text.len] = 0; + out_dimensions.* = dimensions; + out_generation.* = generation; + rc = OK; + } + } + } + _ = c.sqlite3_finalize(stmt); + return rc; +} + +fn vsLoadWorkspaceNativeTokenVector( + s: *Store, + project: CStr, + token: CStr, + out: *FloatVec, + out_found: *bool, + out_generation_mismatch: *bool, +) c_int { + var model: [c.CBM_SZ_256]u8 = undefined; + var dimensions: c_int = 0; + var generation: i64 = 0; + out_found.* = false; + out_generation_mismatch.* = false; + const state_rc = vsWorkspaceVectorState(s, project, &model, &dimensions, &generation); + if (state_rc != OK or dimensions != VS_VEC_DIM) return ERR; + const session = vsGetZovaVectorSession(s, s.db_path); + var session_generation: i64 = 0; + if (session == null or c.cbm_zova_vector_session_generation(session, &session_generation) != 0 or + session_generation != generation) + { + out_generation_mismatch.* = true; + return ERR; + } + var values: KwVec = undefined; + var found = false; + if (c.cbm_zova_vector_session_get_workspace_token_i8( + session, + &s.zova_graph_workspace_id, + &model, + dimensions, + token, + &values, + values.len, + &found, + ) != 0) return ERR; + if (found) vsDequantize(&values, out); + out_found.* = found; + return OK; +} + +/// Try to look up an enriched int8 vector for `token` in the token_vectors +/// table. On success, writes the de-quantized float representation to +/// `out` and returns true. +fn vsLoadEnrichedVector(s: *Store, project: CStr, token: CStr, out: *FloatVec) bool { + const tv_stmt = prepareOne(s.db, "SELECT vector, idf FROM token_vectors" ++ + " WHERE project = ?1 AND token = ?2 LIMIT 1") orelse return false; + var found = false; + _ = c.sqlite3_bind_text(tv_stmt, 1, project, -1, null); + _ = c.sqlite3_bind_text(tv_stmt, 2, token, -1, null); + if (c.sqlite3_step(tv_stmt) == c.SQLITE_ROW) { + const vec: ?*const KwVec = @ptrCast(c.sqlite3_value_blob(c.sqlite3_column_value(tv_stmt, 0))); + const vec_len = c.sqlite3_column_bytes(tv_stmt, 0); + if (vec != null and vec_len == VS_VEC_DIM) { + vsDequantize(vec.?, out); + found = true; + } + } + _ = c.sqlite3_finalize(tv_stmt); + return found; +} + +/// Sparse-random-index fallback for tokens not present in the enriched table. +/// std.hash.XxHash3 gives the values of the vendored XXH3_64bits. +fn vsFillSparseRandom(token: CStr, out: *FloatVec) void { + const seed = std.hash.XxHash3.hash(0, std.mem.span(token)); + for (0..VS_SPARSE_NNZE) |n| { + const i: c_int = @intCast(n); + const h = std.hash.XxHash3.hash(seed +% VS_RI_SEED, std.mem.asBytes(&i)); + const pos: usize = @intCast(h % VS_VEC_DIM); + out[pos] += if (h & 1 != 0) 1.0 else -1.0; + } +} + +/// Clamp one float into the int8 representable range. +fn vsClampInt8(v: f32) i8 { + if (v > CBM_STORE_INT8_MAX) return 127; + if (v < -CBM_STORE_INT8_MAX) return -127; + return @intFromFloat(v); +} + +/// Normalize + int8 quantize a float vector. Returns false if the vector is +/// effectively zero (magnitude below epsilon), in which case `dst` is left in +/// an indeterminate state and the caller should skip this keyword. +fn vsNormalizeAndQuantize(src: *const FloatVec, dst: *KwVec) bool { + var mag: f32 = 0.0; + for (src) |value| mag += value * value; + mag = @sqrt(mag); + if (mag < CBM_STORE_DENOM_EPS_F) return false; + const inv = 1.0 / mag; + for (dst, src) |*quantized, value| quantized.* = vsClampInt8(value * inv * CBM_STORE_INT8_MAX); + return true; +} + +/// Build int8 query vectors for each keyword into kw_vecs and stores how many +/// were built (may be less than the keyword count when some keywords produce +/// zero-magnitude vectors). +fn vsBuildKeywordVectors( + s: *Store, + project: CStr, + keywords: []const CStr, + kw_vecs: *[VS_MAX_KW]KwVec, + out_actual_kw: *usize, + metrics: ?*VectorMetrics, +) c_int { + var actual_kw: usize = 0; + for (keywords) |keyword| { + if (actual_kw >= VS_MAX_KW) break; + if (keyword == null or keyword[0] == 0) continue; + var kw_f = std.mem.zeroes(FloatVec); + var found = false; + if (s.zova_workspace_query) { + var generation_mismatch = false; + if (vsLoadWorkspaceNativeTokenVector(s, project, keyword, &kw_f, &found, &generation_mismatch) != OK) { + if (metrics) |m| { + if (generation_mismatch) m.generation_mismatch = true; + } + return ERR; + } + } else { + found = vsLoadEnrichedVector(s, project, keyword, &kw_f); + } + if (!found) vsFillSparseRandom(keyword, &kw_f); + if (vsNormalizeAndQuantize(&kw_f, &kw_vecs[actual_kw])) actual_kw += 1; + } + out_actual_kw.* = actual_kw; + return OK; +} + +/// Compute the per-keyword min cosine score between a node's int8 vector and +/// each of the query vectors. Returns 0.0 if the node vector is unavailable +/// or mis-sized. +fn vsMinCosineScore(node_vec_opt: ?*const KwVec, node_vec_len: c_int, kw_vecs: []const KwVec) f64 { + const node_vec = node_vec_opt orelse return 0.0; + if (node_vec_len != VS_VEC_DIM) return 0.0; + var min_score: f64 = 1.0; + for (kw_vecs) |*kw_vec| { + var dot: i32 = 0; + var ma: i32 = 0; + var mb: i32 = 0; + for (kw_vec, node_vec) |a, b| { + dot += @as(i32, a) * @as(i32, b); + ma += @as(i32, a) * @as(i32, a); + mb += @as(i32, b) * @as(i32, b); + } + const denom = @sqrt(@as(f64, @floatFromInt(ma))) * @sqrt(@as(f64, @floatFromInt(mb))); + const cos_k = if (denom > CBM_STORE_DENOM_EPS_D) @as(f64, @floatFromInt(dot)) / denom else 0.0; + if (cos_k < min_score) min_score = cos_k; + } + return min_score; +} + +/// strdup of text, or of "" for NULL. +fn dupOrEmpty(text: CStr) [*c]u8 { + return c.strdup(if (text != null) text else ""); +} + +/// Append one candidate row read from the scan statement into the result +/// vector. Grows the results array geometrically on demand. Returns false +/// on allocation failure. +fn vsAppendResult(results: *[*c]VectorResult, count: *c_int, cap: *c_int, stmt: ?*Stmt, kw_vecs: []const KwVec) bool { + if (count.* >= cap.*) { + const nc = if (cap.* < c.CBM_SZ_16) c.CBM_SZ_16 else cap.* * 2; + const grown = interop.reallocN(VectorResult, results.*, @intCast(nc)); + if (grown == null) return false; + results.* = grown; + cap.* = nc; + } + const idx: usize = @intCast(count.*); + count.* += 1; + const node_vec: ?*const KwVec = @ptrCast(c.sqlite3_column_blob(stmt, 6)); + const node_vec_len = c.sqlite3_column_bytes(stmt, 6); + results.*[idx] = .{ + .node_id = c.sqlite3_column_int64(stmt, 0), + .name = dupOrEmpty(columnText(stmt, 1)), + .qualified_name = dupOrEmpty(columnText(stmt, 2)), + .file_path = dupOrEmpty(columnText(stmt, 3)), + .label = dupOrEmpty(columnText(stmt, 4)), + .score = vsMinCosineScore(node_vec, node_vec_len, kw_vecs), + }; + return true; +} + +fn vsSortAndTrim(results_c: [*c]VectorResult, count: *c_int, limit: c_int) void { + const results = span(results_c, count.*); + for (0..results.len) |i| { + for (i + 1..results.len) |j| { + const higher_score = results[j].score > results[i].score; + const stable_tie = results[j].score == results[i].score and + c.strcmp(safeStr(results[j].qualified_name), safeStr(results[i].qualified_name)) < 0; + if (higher_score or stable_tie) std.mem.swap(VectorResult, &results[i], &results[j]); + } + } + + const final_limit: usize = @intCast(if (limit > 0) limit else c.CBM_SZ_16); + if (results.len > final_limit) { + for (results[final_limit..]) |*result| vectorResultFreeFields(result); + count.* = @intCast(final_limit); + } +} + +fn vsElapsedMs(start: *const c.struct_timespec, end: *const c.struct_timespec) f64 { + return (@as(f64, @floatFromInt(end.tv_sec - start.tv_sec)) * 1000.0) + + (@as(f64, @floatFromInt(end.tv_nsec - start.tv_nsec)) / 1000000.0); +} + +fn vsRecordElapsed(start: *const c.struct_timespec, out_ms: *f64) void { + var end: c.struct_timespec = undefined; + _ = c.cbm_clock_gettime(c.CLOCK_MONOTONIC, &end); + out_ms.* = vsElapsedMs(start, &end); +} + +fn storeCloseZovaVectorSession(s: *Store) void { + c.cbm_zova_vector_session_close(s.zova_vector_session); + s.zova_vector_session = null; + c.free(s.zova_vector_session_path); + s.zova_vector_session_path = null; + s.zova_vector_session_stat_valid = false; +} + +/// st_mtime, which POSIX defines as a macro over a timespec field. +fn statMtime(st: *const c.struct_stat) i64 { + if (builtin.os.tag.isDarwin()) return st.st_mtimespec.tv_sec; + if (builtin.os.tag == .windows) return st.st_mtime; + return st.st_mtim.tv_sec; +} + +fn vsZovaSessionMatches(s: *const Store, zova_path: CStr, st: *const c.struct_stat) bool { + return s.zova_vector_session != null and s.zova_vector_session_path != null and + s.zova_vector_session_stat_valid and streq(s.zova_vector_session_path, zova_path) and + s.zova_vector_session_stat.st_dev == st.st_dev and + s.zova_vector_session_stat.st_ino == st.st_ino and + s.zova_vector_session_stat.st_size == st.st_size and + statMtime(&s.zova_vector_session_stat) == statMtime(st); +} + +fn vsGetZovaVectorSession(s: *Store, zova_path: CStr) ?*VectorSession { + var st: c.struct_stat = undefined; + if (zova_path == null or c.stat(zova_path, &st) != 0) return null; + if (vsZovaSessionMatches(s, zova_path, &st)) return s.zova_vector_session; + storeCloseZovaVectorSession(s); + const session = c.cbm_zova_vector_session_open(zova_path); + const path_copy: [*c]u8 = if (session != null) interop.heapStrdup(zova_path) else null; + if (session == null or path_copy == null) { + c.cbm_zova_vector_session_close(session); + return null; + } + s.zova_vector_session = session; + s.zova_vector_session_path = path_copy; + s.zova_vector_session_stat = st; + s.zova_vector_session_stat_valid = true; + return session; +} + +fn storeCloseZovaGraphSession(s: *Store) void { + c.cbm_zova_graph_session_close(s.zova_graph_session); + s.zova_graph_session = null; +} + +fn storeClearZovaGraphWorkspace(s: *Store) void { + c.free(s.zova_graph_workspace_root); + s.zova_graph_workspace_root = null; + s.zova_graph_workspace_id[0] = 0; +} + +/// The positive node key a vector hit names; null when it is not one. +fn vsHitKey(vector_id: [*c]const u8) ?i64 { + if (vector_id == null) return null; + var end: [*c]u8 = null; + const key = c.strtoll(vector_id, &end, 10); + if (end == vector_id or end[0] != 0 or key <= 0) return null; + return key; +} + +/// True for the node labels vector search reports. +fn vsIsCodeLabel(label: CStr) bool { + return label != null and (streq(label, "Function") or streq(label, "Method") or streq(label, "Class")); +} + +const vs_collection_max = c.CBM_ZOVA_WORKSPACE_ID_MAX + 384; + +fn vsTryZovaWorkspaceNativeVectorSearch( + s: *Store, + project: CStr, + kw_vecs: []const KwVec, + limit: c_int, + out: *[*c]VectorResult, + out_count: *c_int, + metrics: ?*VectorMetrics, +) c_int { + if (!s.zova_workspace_query) return NOT_FOUND; + + var model: [c.CBM_SZ_256]u8 = undefined; + var vector_dimensions: c_int = 0; + var generation: i64 = 0; + if (vsWorkspaceVectorState(s, project, &model, &vector_dimensions, &generation) != OK or + vector_dimensions != VS_VEC_DIM) + { + return ERR; + } + const session = vsGetZovaVectorSession(s, s.db_path); + var session_generation: i64 = 0; + if (session == null or c.cbm_zova_vector_session_generation(session, &session_generation) != 0 or + session_generation != generation) + { + if (metrics) |m| m.generation_mismatch = true; + return ERR; + } + var collection: [vs_collection_max]u8 = undefined; + if (c.cbm_zova_workspace_node_vector_collection_name( + &s.zova_graph_workspace_id, + &model, + vector_dimensions, + &collection, + collection.len, + ) != 0) return ERR; + + const requested_limit = if (limit > 0) limit else c.CBM_SZ_16; + const fetch_limit = requested_limit *| 5; + var started: c.struct_timespec = undefined; + _ = c.cbm_clock_gettime(c.CLOCK_MONOTONIC, &started); + var hits: [*c]c.cbm_zova_vector_hit_t = null; + var hit_count: c_int = 0; + if (c.cbm_zova_vector_session_search_collection_i8( + session, + &collection, + &kw_vecs[0], + @intCast(kw_vecs.len), + vector_dimensions, + fetch_limit, + fetch_limit, + &hits, + &hit_count, + ) != 0) return ERR; + defer c.cbm_zova_vector_hits_free(hits, hit_count); + if (metrics) |m| { + m.used_zova = true; + m.zova_native_multi_query = kw_vecs.len > 1; + m.zova_fetch_limit = fetch_limit; + vsRecordElapsed(&started, &m.zova_prefetch_ms); + } + if (hit_count == 0) return OK; + + if (s.zova_repository == null) return ERR; + const hit_n: usize = @intCast(hit_count); + const node_keys = callocSlice(i64, hit_n) orelse return ERR; + defer c.free(node_keys.ptr); + for (node_keys, hits[0..hit_n]) |*node_key, *hit| { + node_key.* = vsHitKey(hit.vector_id) orelse return ERR; + } + const results = callocSlice(VectorResult, hit_n) orelse return ERR; + var nodes: [*c]c.cbm_node_t = null; + if (c.cbm_zova_repository_find_nodes_by_keys( + s.zova_repository, + &s.zova_graph_workspace_id, + node_keys.ptr, + hit_count, + &nodes, + ) != OK) { + c.free(results.ptr); + return ERR; + } + var count: c_int = 0; + var rc: c_int = OK; + for (nodes[0..hit_n], hits[0..hit_n]) |*node, *hit| { + if (!vsIsCodeLabel(node.label)) { + cbm_node_free_fields(node); + continue; + } + const item = &results[@intCast(count)]; + count += 1; + // The node's strings move into the result. + item.* = .{ + .node_id = node.id, + .name = @constCast(node.name), + .qualified_name = @constCast(node.qualified_name), + .file_path = @constCast(node.file_path), + .label = @constCast(node.label), + .score = hit.score, + }; + node.name = null; + node.qualified_name = null; + node.file_path = null; + node.label = null; + cbm_node_free_fields(node); + if (item.name == null or item.qualified_name == null or item.file_path == null or item.label == null) { + rc = ERR; + break; + } + } + cbm_store_free_nodes(nodes, hit_count); + if (rc != OK) { + cbm_store_free_vector_results(results.ptr, count); + return ERR; + } + vsSortAndTrim(results.ptr, &count, limit); + out.* = results.ptr; + out_count.* = count; + return OK; +} + +fn vsTryZovaVectorSearch( + s: *Store, + project: CStr, + kw_vecs: []const KwVec, + limit: c_int, + out: *[*c]VectorResult, + out_count: *c_int, + metrics: ?*VectorMetrics, +) c_int { + if (!c.cbm_zova_build_enabled() or s.db_path == null or !s.zova_workspace_query) return NOT_FOUND; + return vsTryZovaWorkspaceNativeVectorSearch(s, project, kw_vecs, limit, out, out_count, metrics); +} + +pub export fn cbm_store_vector_search_ex( + s_c: ?*Store, + project: CStr, + keywords: [*c]CStr, + keyword_count: c_int, + limit: c_int, + out: *[*c]VectorResult, + out_count: *c_int, + metrics: ?*VectorMetrics, +) c_int { + if (metrics) |m| m.* = std.mem.zeroes(VectorMetrics); + out.* = null; + out_count.* = 0; + const s = s_c orelse return ERR; + if (project == null or keywords == null or keyword_count <= 0) return ERR; + + var stage_start: c.struct_timespec = undefined; + _ = c.cbm_clock_gettime(c.CLOCK_MONOTONIC, &stage_start); + var kw_storage: [VS_MAX_KW]KwVec = undefined; + var actual_kw: usize = 0; + if (vsBuildKeywordVectors(s, project, span(keywords, keyword_count), &kw_storage, &actual_kw, metrics) != OK) { + return ERR; + } + if (metrics) |m| vsRecordElapsed(&stage_start, &m.query_vector_build_ms); + if (actual_kw == 0) return OK; + const kw_vecs = kw_storage[0..actual_kw]; + + const zova_rc = vsTryZovaVectorSearch(s, project, kw_vecs, limit, out, out_count, metrics); + if (zova_rc == OK) return OK; + if (zova_rc == ERR) return ERR; + if (s.zova_workspace_query) return ERR; + + // Scan all node vectors, compute per-keyword cosine, take min. + // We use the FIRST keyword as the SQL sort (for top-K pre-filter), + // then re-score with min across all keywords in the append helper. + var stmt: ?*Stmt = null; + if (prepare(s.db, "SELECT n.id,n.name,n.qualified_name,n.file_path,n.label," ++ + "cbm_cosine_i8(v.vector,?1) AS score,v.vector FROM node_vectors v " ++ + "INNER JOIN nodes n ON n.id=v.node_id WHERE v.project=?2 " ++ + "AND n.label IN ('Function','Method','Class') ORDER BY score DESC LIMIT ?3", &stmt) != c.SQLITE_OK) + { + _ = c.fprintf(interop.stderr(), "vector_search: %s\n", c.sqlite3_errmsg(s.db)); + return ERR; + } + + // Use first keyword for SQL pre-filter, fetch more candidates for re-ranking + const fetch_limit = (if (limit > 0) limit else c.CBM_SZ_16) *| 5; + _ = c.sqlite3_bind_blob(stmt, 1, &kw_vecs[0], VS_VEC_DIM, null); + _ = c.sqlite3_bind_text(stmt, 2, project, -1, null); + _ = c.sqlite3_bind_int(stmt, 3, fetch_limit); + + { + var kw_buf: [VS_STR_BUF]u8 = undefined; + var fl_buf: [VS_STR_BUF]u8 = undefined; + _ = interop.bprint(&kw_buf, "{d}", .{actual_kw}); + _ = interop.bprint(&fl_buf, "{d}", .{fetch_limit}); + interop.logInfo("vector_search.exec", .{ "kw_count", @as(CStr, &kw_buf), "fetch_limit", @as(CStr, &fl_buf), "project", project }); + } + + var results: [*c]VectorResult = null; + var count: c_int = 0; + var cap: c_int = 0; + var step_rc = c.sqlite3_step(stmt); + while (step_rc == c.SQLITE_ROW) : (step_rc = c.sqlite3_step(stmt)) { + if (!vsAppendResult(&results, &count, &cap, stmt, kw_vecs)) break; + } + + if (step_rc != c.SQLITE_DONE) { + var rc_buf: [VS_STR_BUF]u8 = undefined; + _ = interop.bprint(&rc_buf, "{d}", .{step_rc}); + interop.logWarn("vector_search.step_error", .{ "rc", @as(CStr, &rc_buf), "msg", c.sqlite3_errmsg(s.db) }); + } + { + var cnt_buf: [VS_STR_BUF]u8 = undefined; + _ = interop.bprint(&cnt_buf, "{d}", .{count}); + interop.logInfo("vector_search.done", .{ "candidates", @as(CStr, &cnt_buf) }); + } + _ = c.sqlite3_finalize(stmt); + + vsSortAndTrim(results, &count, limit); + + out.* = results; + out_count.* = count; + return OK; +} + +pub export fn cbm_store_vector_search( + s: ?*Store, + project: CStr, + keywords: [*c]CStr, + keyword_count: c_int, + limit: c_int, + out: *[*c]VectorResult, + out_count: *c_int, +) c_int { + return cbm_store_vector_search_ex(s, project, keywords, keyword_count, limit, out, out_count, null); +} + +fn multiVectorResultCompare(left: ?*const anyopaque, right: ?*const anyopaque) callconv(.c) c_int { + const a: *const VectorResult = @ptrCast(@alignCast(left)); + const b: *const VectorResult = @ptrCast(@alignCast(right)); + if (a.score > b.score) return -1; + if (a.score < b.score) return 1; + const by_project = c.strcmp(a.project, b.project); + if (by_project != 0) return by_project; + const by_qn = c.strcmp(a.qualified_name, b.qualified_name); + if (by_qn != 0) return by_qn; + return order(a.node_id, b.node_id); +} + +/// The results of every workspace searched so far. +const CombinedResults = struct { + items: [*c]VectorResult = null, + count: c_int = 0, + capacity: c_int = 0, +}; + +/// Searches one workspace and appends its hits, hydrated from the store, to +/// combined. +fn multiVectorSearchWorkspace( + session: ?*VectorSession, + hydrate: ?*Stmt, + workspace: *const c.cbm_zova_vector_workspace_t, + keywords: []const CStr, + needed: c_int, + combined: *CombinedResults, +) c_int { + if (workspace.workspace_id == null or workspace.selector == null or workspace.model_fingerprint == null or + workspace.vector_dimensions != VS_VEC_DIM) + { + return ERR; + } + var query_vectors: [VS_MAX_KW]KwVec = undefined; + var query_count: usize = 0; + for (keywords) |keyword| { + if (query_count >= VS_MAX_KW) break; + if (keyword == null or keyword[0] == 0) continue; + var token_vector: KwVec = undefined; + var found = false; + var values = std.mem.zeroes(FloatVec); + if (c.cbm_zova_vector_session_get_workspace_token_i8( + session, + workspace.workspace_id, + workspace.model_fingerprint, + workspace.vector_dimensions, + keyword, + &token_vector, + token_vector.len, + &found, + ) != 0) return ERR; + if (found) { + vsDequantize(&token_vector, &values); + } else { + vsFillSparseRandom(keyword, &values); + } + if (vsNormalizeAndQuantize(&values, &query_vectors[query_count])) query_count += 1; + } + if (query_count == 0) return OK; + + var collection: [vs_collection_max]u8 = undefined; + if (c.cbm_zova_workspace_node_vector_collection_name( + workspace.workspace_id, + workspace.model_fingerprint, + workspace.vector_dimensions, + &collection, + collection.len, + ) != 0) return ERR; + const prefilter = if (query_count > 1 and needed <= @divTrunc(std.math.maxInt(c_int), 5)) needed * 5 else needed; + var hits: [*c]c.cbm_zova_vector_hit_t = null; + var hit_count: c_int = 0; + if (c.cbm_zova_vector_session_search_collection_i8( + session, + &collection, + &query_vectors[0], + @intCast(query_count), + VS_VEC_DIM, + prefilter, + needed, + &hits, + &hit_count, + ) != 0) return ERR; + defer c.cbm_zova_vector_hits_free(hits, hit_count); + + for (span(hits, hit_count)) |*hit| { + const key = vsHitKey(hit.vector_id) orelse return ERR; + if (c.sqlite3_reset(hydrate) != c.SQLITE_OK or + c.sqlite3_clear_bindings(hydrate) != c.SQLITE_OK or + c.sqlite3_bind_int64(hydrate, 1, workspace.workspace_key) != c.SQLITE_OK or + c.sqlite3_bind_int64(hydrate, 2, key) != c.SQLITE_OK) + { + return ERR; + } + if (c.sqlite3_step(hydrate) != c.SQLITE_ROW) return ERR; + const label = columnText(hydrate, 0); + if (!vsIsCodeLabel(label)) continue; + if (combined.count == combined.capacity) { + if (combined.capacity > @divTrunc(std.math.maxInt(c_int), 2)) return ERR; + const next = if (combined.capacity != 0) combined.capacity * 2 else c.CBM_SZ_16; + const grown = interop.reallocN(VectorResult, combined.items, @intCast(next)); + if (grown == null) return ERR; + combined.items = grown; + combined.capacity = next; + } + const result: VectorResult = .{ + .node_id = key, + .project = c.strdup(workspace.selector), + .label = c.strdup(label), + .name = dupOrEmpty(columnText(hydrate, 1)), + .qualified_name = dupOrEmpty(columnText(hydrate, 2)), + .file_path = dupOrEmpty(columnText(hydrate, 3)), + .score = hit.score, + }; + if (result.project == null or result.label == null or result.name == null or + result.qualified_name == null or result.file_path == null) + { + vectorResultFreeFields(&result); + return ERR; + } + combined.items[@intCast(combined.count)] = result; + combined.count += 1; + } + return OK; +} + +pub export fn cbm_store_zova_multi_vector_search( + db_path: CStr, + workspaces: [*c]const c.cbm_zova_vector_workspace_t, + workspace_count: c_int, + keywords: [*c]CStr, + keyword_count: c_int, + limit: c_int, + offset: c_int, + out_c: ?*[*c]VectorResult, + out_count_c: ?*c_int, +) c_int { + const out = out_c orelse return ERR; + const out_count = out_count_c orelse return ERR; + out.* = null; + out_count.* = 0; + if (db_path == null or workspaces == null or workspace_count <= 0 or keywords == null or keyword_count <= 0 or + limit <= 0 or offset < 0 or offset > std.math.maxInt(c_int) - limit) + { + return ERR; + } + const needed = offset + limit; + const store = cbm_store_open_path_query(@ptrCast(db_path)); + const session = c.cbm_zova_vector_session_open(db_path); + // Deferred in reverse: the statement, then the store, then the session. + defer c.cbm_zova_vector_session_close(session); + defer cbm_store_close(store); + const s = store orelse return ERR; + if (session == null) return ERR; + const hydrate = prepareOne(s.db, "SELECT n.label,n.name,n.qualified_name,f.file_path FROM cbm_nodes_v1 n " ++ + "JOIN cbm_files_v1 f ON f.file_key=n.file_key WHERE n.workspace_key=?1 " ++ + "AND n.zova_node_key=?2") orelse return ERR; + defer _ = c.sqlite3_finalize(hydrate); + + var combined: CombinedResults = .{}; + for (span(workspaces, workspace_count)) |*workspace| { + const rc = multiVectorSearchWorkspace(session, hydrate, workspace, span(keywords, keyword_count), needed, &combined); + if (rc != OK) { + cbm_store_free_vector_results(combined.items, combined.count); + return rc; + } + } + if (combined.count > 1) { + c.qsort(combined.items, @intCast(combined.count), @sizeOf(VectorResult), multiVectorResultCompare); + } + const start: usize = @intCast(@min(offset, combined.count)); + const end: usize = @intCast(@min(offset + limit, combined.count)); + const page_count = end - start; + var page: [*c]VectorResult = null; + if (page_count != 0) { + page = interop.callocN(VectorResult, page_count); + if (page == null) { + cbm_store_free_vector_results(combined.items, combined.count); + return ERR; + } + // The page takes the strings of its rows. + for (page[0..page_count], combined.items[start..end]) |*row, *source| { + row.* = source.*; + source.* = std.mem.zeroes(VectorResult); + } + } + cbm_store_free_vector_results(combined.items, combined.count); + out.* = page; + out_count.* = @intCast(page_count); + return OK; +} diff --git a/tests/test_github_workflows.sh b/tests/test_github_workflows.sh index 774a58e6..22ce7c51 100644 --- a/tests/test_github_workflows.sh +++ b/tests/test_github_workflows.sh @@ -138,7 +138,7 @@ grep -q 'checksums.txt' "$WORKFLOWS/release.yml" grep -q '#define cbm_lstat' "$ROOT/src/foundation/compat.h" grep -q '#define cbm_mkdir_mode' "$ROOT/src/foundation/compat.h" for path in \ - "$ROOT/src/store/store.c" \ + "$ROOT/src/store/store.zig" \ "$ROOT/src/zova/cbm_zova_operations.c" \ "$ROOT/src/cli/cli.zig" \ "$ROOT/src/cli/commands.zig"; do From 135d3f8f853cd818df6830ad8bc26d2c349d4beb Mon Sep 17 00:00:00 2001 From: ata-sesli Date: Tue, 10 Nov 2026 21:02:44 +0300 Subject: [PATCH 3/9] refactor(zova): port the small src/zova modules from C to Zig Ports seven src/zova files that do not call the Zova SDK. The headers stay the ABI; each .c is replaced by a Zig module with the same exported symbols and checked by checkExports. cbm_zova_route.c -> cbm_zova_route.zig cbm_zova_bridge_stub.c -> cbm_zova_bridge_stub.zig cbm_zova_writer_gate.c -> cbm_zova_writer_gate.zig cbm_zova_edge_payload.c -> cbm_zova_edge_payload.zig cbm_zova_coordination.c -> cbm_zova_coordination.zig cbm_zova_v5_snapshot.c -> cbm_zova_v5_snapshot.zig cbm_zova_delta.c -> cbm_zova_delta.zig 1,862 lines of C removed, 1,806 lines of Zig added. Build wiring: - translate-c now receives -DCBM_WITH_ZOVA=0|1 and, with Zova, the SDK include directory (Makefile.cbm, build.zig, the Windows prebuilt step in _build.yml). The Zig side reads c.CBM_WITH_ZOVA. cbm_zig_c.h includes zova.h when the macro is 1. - The bridge stub is compiled only when CBM_WITH_ZOVA is 0, as before (the C file was an empty translation unit otherwise). - The writer gate uses cbm_once from compat_thread instead of calling InitOnceExecuteOnce / pthread_once directly. Behavior differences: - cbm_zova_workspace_delta_build: the endpoints of a deleted edge are read through the same bounds-checked helper as every other edge. A `before` model whose edge has a source_id or target_id outside its node array now fails with -1 instead of reading outside node_stable_ids. The edge count sum before + after saturates instead of wrapping. - cbm_zova_v5_snapshot_open: when an allocation fails after a row count was stored, the cleanup path no longer walks a NULL array. - Coordination deadlines use saturating addition, so a very large wait_ms cannot overflow int64. Not changed, reported for review: the canonical-varint overflow guard in the edge payload reader (shift == bits - 7) can never be true on a 64-bit size_t (shift is a multiple of 7, 57 is not). It is kept exactly as in C. A tenth byte with bits above the top bit is therefore truncated rather than rejected, in both the C and the Zig version. Verified: zova 39, zova_operations 59, zova_incremental_native 1, zova_plugin 8, zova_bridge 4, zova_c_sql_functions 3, mcp 176, cli 132 (all equal to the baseline on main); libcbm_zig.a builds natively with and without Zova; zig fmt --check, check-build-sources.sh, security-audit.sh and tests/test_github_workflows.sh pass. Signed-off-by: ata-sesli --- .github/workflows/_build.yml | 1 + Makefile.cbm | 21 +- build.zig | 11 +- src/cbm_zig.zig | 7 + src/cbm_zig_c.h | 13 +- src/zova/cbm_zova_bridge_stub.c | 55 --- src/zova/cbm_zova_bridge_stub.zig | 45 ++ src/zova/cbm_zova_coordination.c | 252 ----------- src/zova/cbm_zova_coordination.zig | 246 +++++++++++ src/zova/cbm_zova_delta.c | 559 ------------------------ src/zova/cbm_zova_delta.zig | 628 +++++++++++++++++++++++++++ src/zova/cbm_zova_edge_payload.c | 211 --------- src/zova/cbm_zova_edge_payload.zig | 195 +++++++++ src/zova/cbm_zova_route.c | 12 - src/zova/cbm_zova_route.zig | 14 + src/zova/cbm_zova_v5_snapshot.c | 663 ----------------------------- src/zova/cbm_zova_v5_snapshot.zig | 586 +++++++++++++++++++++++++ src/zova/cbm_zova_writer_gate.c | 110 ----- src/zova/cbm_zova_writer_gate.zig | 92 ++++ 19 files changed, 1842 insertions(+), 1879 deletions(-) delete mode 100644 src/zova/cbm_zova_bridge_stub.c create mode 100644 src/zova/cbm_zova_bridge_stub.zig delete mode 100644 src/zova/cbm_zova_coordination.c create mode 100644 src/zova/cbm_zova_coordination.zig delete mode 100644 src/zova/cbm_zova_delta.c create mode 100644 src/zova/cbm_zova_delta.zig delete mode 100644 src/zova/cbm_zova_edge_payload.c create mode 100644 src/zova/cbm_zova_edge_payload.zig delete mode 100644 src/zova/cbm_zova_route.c create mode 100644 src/zova/cbm_zova_route.zig delete mode 100644 src/zova/cbm_zova_v5_snapshot.c create mode 100644 src/zova/cbm_zova_v5_snapshot.zig delete mode 100644 src/zova/cbm_zova_writer_gate.c create mode 100644 src/zova/cbm_zova_writer_gate.zig diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 755ac008..ba41736b 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -109,6 +109,7 @@ jobs: mkdir -p .ci/windows-zig "$RUNNER_TEMP/cbm-zig-local" "$RUNNER_TEMP/cbm-zig-global" # CBM's Zig modules read C declarations from a translate-c module "c". zig translate-c -lc -target "$TARGET" -D_DEFAULT_SOURCE -D_GNU_SOURCE \ + -DCBM_WITH_ZOVA=1 -I"$ZOVA_ROOT/include" \ -Isrc -Ivendored -Ivendored/sqlite3 -Ivendored/mimalloc/include \ -Iinternal/cbm -Iinternal/cbm/vendored/ts_runtime/include \ src/cbm_zig_c.h > "$RUNNER_TEMP/cbm_c.zig" diff --git a/Makefile.cbm b/Makefile.cbm index 3acad655..296fb3e7 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -154,15 +154,8 @@ PREPROCESSOR_SRC = $(CBM_DIR)/preprocessor.cpp ZOVA_SRCS = \ src/zova/cbm_zova.c \ src/zova/cbm_zova_publish_model.c \ - src/zova/cbm_zova_edge_payload.c \ - src/zova/cbm_zova_delta.c \ src/zova/cbm_zova_operations.c \ - src/zova/cbm_zova_v5_snapshot.c \ - src/zova/cbm_zova_writer_gate.c \ - src/zova/cbm_zova_coordination.c \ - src/zova/cbm_zova_route.c \ src/zova/cbm_zova_repository.c \ - src/zova/cbm_zova_bridge_stub.c \ src/zova/cbm_zova_plugin.c # MCP server module: implemented in Zig (src/mcp/*.zig, part of libcbm_zig) @@ -491,9 +484,21 @@ CBM_ZIG_SRCS = $(CBM_ZIG_SRC) src/cbm_zig_c.h src/cli_zig/cbm_cli_zig.zig src/cl src/graph_buffer/graph_buffer.zig src/graph_buffer/graph_buffer.h \ src/simhash/minhash.zig src/simhash/minhash.h \ src/store/store.zig src/store/store.h \ - src/watcher/watcher.zig src/watcher/watcher.h + src/watcher/watcher.zig src/watcher/watcher.h \ + src/zova/cbm_zova_bridge_stub.zig src/zova/cbm_zova_coordination.zig \ + src/zova/cbm_zova_delta.zig src/zova/cbm_zova_edge_payload.zig \ + src/zova/cbm_zova_route.zig src/zova/cbm_zova_v5_snapshot.zig \ + src/zova/cbm_zova_writer_gate.zig \ + $(wildcard src/zova/*.h) +# The Zig modules see CBM_WITH_ZOVA and, with Zova, the SDK header too. CBM_ZIG_C_INCLUDES = -Isrc -Ivendored -Ivendored/sqlite3 -Ivendored/mimalloc/include \ -I$(CBM_DIR) -I$(TS_INCLUDE) +ifeq ($(CBM_WITH_ZOVA),1) +CBM_ZIG_C_INCLUDES += -DCBM_WITH_ZOVA=1 -I$(ZOVA_ROOT)/include +CBM_ZIG_SRCS += $(ZOVA_ROOT)/include/zova.h +else +CBM_ZIG_C_INCLUDES += -DCBM_WITH_ZOVA=0 +endif CBM_ZIG_LIB = $(BUILD_DIR)/libcbm_zig.a # Test runners link a ReleaseSafe copy: the sanitizers do not instrument Zig # code, so its runtime safety checks are what catch bounds and overflow bugs. diff --git a/build.zig b/build.zig index f9abeae3..4447700e 100644 --- a/build.zig +++ b/build.zig @@ -419,6 +419,10 @@ fn addCbmZigLibrary(b: *std.Build, cfg: Config, kind: BuildKind) *std.Build.Step }); c_decls.defineCMacro("_DEFAULT_SOURCE", null); c_decls.defineCMacro("_GNU_SOURCE", null); + c_decls.defineCMacro("CBM_WITH_ZOVA", if (cfg.with_zova) "1" else "0"); + if (cfg.with_zova) { + c_decls.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ cfg.zova_root, "include" }) }); + } for ([_][]const u8{ "src", "vendored", @@ -534,15 +538,8 @@ const native_sources = [_][]const u8{ "src/foundation/c_shim.c", "src/zova/cbm_zova.c", "src/zova/cbm_zova_publish_model.c", - "src/zova/cbm_zova_edge_payload.c", - "src/zova/cbm_zova_delta.c", "src/zova/cbm_zova_operations.c", - "src/zova/cbm_zova_v5_snapshot.c", - "src/zova/cbm_zova_writer_gate.c", - "src/zova/cbm_zova_coordination.c", - "src/zova/cbm_zova_route.c", "src/zova/cbm_zova_repository.c", - "src/zova/cbm_zova_bridge_stub.c", "src/zova/cbm_zova_plugin.c", "src/pipeline/fqn.c", "src/pipeline/path_alias.c", diff --git a/src/cbm_zig.zig b/src/cbm_zig.zig index 8993560c..3360078a 100644 --- a/src/cbm_zig.zig +++ b/src/cbm_zig.zig @@ -64,6 +64,13 @@ comptime { checkExports(@import("simhash/minhash.zig")); checkExports(@import("store/store.zig")); checkExports(@import("watcher/watcher.zig")); + if (c.CBM_WITH_ZOVA == 0) checkExports(@import("zova/cbm_zova_bridge_stub.zig")); + checkExports(@import("zova/cbm_zova_coordination.zig")); + checkExports(@import("zova/cbm_zova_delta.zig")); + checkExports(@import("zova/cbm_zova_edge_payload.zig")); + checkExports(@import("zova/cbm_zova_route.zig")); + checkExports(@import("zova/cbm_zova_v5_snapshot.zig")); + checkExports(@import("zova/cbm_zova_writer_gate.zig")); } /// Checks every pub `cbm_*` function of `module` against the C declaration diff --git a/src/cbm_zig_c.h b/src/cbm_zig_c.h index 496d6d50..be2adefe 100644 --- a/src/cbm_zig_c.h +++ b/src/cbm_zig_c.h @@ -6,8 +6,9 @@ * Makefile.cbm). Include only what a ported module calls, so a header that * translate-c cannot handle never blocks an unrelated module. * - * Translated without CBM_WITH_ZOVA: the declarations used here do not depend - * on it, and leaving it unset keeps the Zova SDK headers out of the build. + * The build passes -DCBM_WITH_ZOVA=0|1 to translate-c. With Zova, the SDK + * header zova.h is translated too, and the Zig Zova modules compile their + * real implementation instead of the no-Zova stubs. */ #ifndef CBM_ZIG_C_H #define CBM_ZIG_C_H @@ -94,12 +95,20 @@ #include "semantic/semantic.h" #include "store/store.h" #include "tree_sitter/api.h" +#include "zova/cbm_zova.h" +#include "zova/cbm_zova_bridge.h" #include "zova/cbm_zova_coordination.h" #include "zova/cbm_zova_delta.h" +#include "zova/cbm_zova_edge_payload.h" #include "zova/cbm_zova_operations.h" #include "zova/cbm_zova_publish_model.h" #include "zova/cbm_zova_repository.h" #include "zova/cbm_zova_route.h" +#include "zova/cbm_zova_v5_snapshot.h" +#include "zova/cbm_zova_writer_gate.h" +#if CBM_WITH_ZOVA +#include +#endif #include #include diff --git a/src/zova/cbm_zova_bridge_stub.c b/src/zova/cbm_zova_bridge_stub.c deleted file mode 100644 index 30a057b9..00000000 --- a/src/zova/cbm_zova_bridge_stub.c +++ /dev/null @@ -1,55 +0,0 @@ -#include "zova/cbm_zova_bridge.h" - -#ifndef CBM_WITH_ZOVA -#define CBM_WITH_ZOVA 0 -#endif - -#if !CBM_WITH_ZOVA - -int cbm_zova_bridge_build_enabled(void) { - return 0; -} - -int cbm_zova_bridge_install_extension(const char *zova_path) { - (void)zova_path; - return -1; -} - -int cbm_zova_bridge_cosine_smoke(const char *zova_path, const int8_t *a, const int8_t *b, - size_t len, double *out_score) { - (void)zova_path; - (void)a; - (void)b; - (void)len; - (void)out_score; - return -1; -} - -int cbm_zova_bridge_camel_smoke(const char *zova_path, const char *input, char *out, - size_t out_len) { - (void)zova_path; - (void)input; - (void)out; - (void)out_len; - return -1; -} - -int cbm_zova_bridge_regex_smoke(const char *zova_path, const char *pattern, const char *text, - int case_insensitive, int *out_match) { - (void)zova_path; - (void)pattern; - (void)text; - (void)case_insensitive; - (void)out_match; - return -1; -} - -int cbm_zova_bridge_invalid_regex_smoke(const char *zova_path, char *out_error, - size_t out_error_len) { - (void)zova_path; - (void)out_error; - (void)out_error_len; - return -1; -} - -#endif diff --git a/src/zova/cbm_zova_bridge_stub.zig b/src/zova/cbm_zova_bridge_stub.zig new file mode 100644 index 00000000..f122e491 --- /dev/null +++ b/src/zova/cbm_zova_bridge_stub.zig @@ -0,0 +1,45 @@ +//! cbm_zova_bridge_stub.zig — src/zova/cbm_zova_bridge.h for builds without +//! Zova. cbm_zig.zig imports this file only when CBM_WITH_ZOVA is 0; with +//! Zova, cbm_zova_bridge.zig provides the same symbols. + +pub export fn cbm_zova_bridge_build_enabled() c_int { + return 0; +} + +pub export fn cbm_zova_bridge_install_extension(zova_path: ?[*:0]const u8) c_int { + _ = zova_path; + return -1; +} + +pub export fn cbm_zova_bridge_cosine_smoke(zova_path: ?[*:0]const u8, a: ?[*]const i8, b: ?[*]const i8, len: usize, out_score: ?*f64) c_int { + _ = zova_path; + _ = a; + _ = b; + _ = len; + _ = out_score; + return -1; +} + +pub export fn cbm_zova_bridge_camel_smoke(zova_path: ?[*:0]const u8, input: ?[*:0]const u8, out: ?[*]u8, out_len: usize) c_int { + _ = zova_path; + _ = input; + _ = out; + _ = out_len; + return -1; +} + +pub export fn cbm_zova_bridge_regex_smoke(zova_path: ?[*:0]const u8, pattern: ?[*:0]const u8, text: ?[*:0]const u8, case_insensitive: c_int, out_match: ?*c_int) c_int { + _ = zova_path; + _ = pattern; + _ = text; + _ = case_insensitive; + _ = out_match; + return -1; +} + +pub export fn cbm_zova_bridge_invalid_regex_smoke(zova_path: ?[*:0]const u8, out_error: ?[*]u8, out_error_len: usize) c_int { + _ = zova_path; + _ = out_error; + _ = out_error_len; + return -1; +} diff --git a/src/zova/cbm_zova_coordination.c b/src/zova/cbm_zova_coordination.c deleted file mode 100644 index dafed889..00000000 --- a/src/zova/cbm_zova_coordination.c +++ /dev/null @@ -1,252 +0,0 @@ -#include "zova/cbm_zova_coordination.h" - -#include "foundation/compat.h" -#include "foundation/compat_fs.h" -#include "foundation/compat_thread.h" -#include "zova/cbm_zova.h" -#include "zova/cbm_zova_route.h" - -#include -#include -#include -#include -#include - -enum { COORD_POLL_MS = 20, COORD_DEFAULT_WAIT_S = 300 }; - -/* In-process accounting. A file lock alone cannot exclude threads of one - * process, and a second descriptor on the same file would drop the process's - * lock when closed. So each lock file is held at most once per process, and - * this table records who in the process holds it. */ -typedef struct coord_entry { - struct coord_entry *next; - char path[4096]; - int shared; /* in-process shared holders */ - bool exclusive; - cbm_file_lock_t lock; -} coord_entry_t; - -static coord_entry_t *coord_entries; -static cbm_mutex_t coord_mutex; -static atomic_bool coord_cancelled; - -static cbm_once_t coord_once = CBM_ONCE_INIT; -static void coord_init(void) { - cbm_mutex_init(&coord_mutex); -} -static void coord_ensure_initialized(void) { - cbm_once(&coord_once, coord_init); -} - -static coord_entry_t **coord_find(const char *path) { - for (coord_entry_t **e = &coord_entries; *e; e = &(*e)->next) { - if (strcmp((*e)->path, path) == 0) return e; - } - return NULL; -} - -/* The cache directory may not exist before the first index run. */ -static bool coord_ensure_parent_dir(const char *path) { - char dir[4096]; - if (snprintf(dir, sizeof(dir), "%s", path) >= (int)sizeof(dir)) return false; - char *slash = strrchr(dir, '/'); -#ifdef _WIN32 - char *backslash = strrchr(dir, '\\'); - if (!slash || (backslash && backslash > slash)) slash = backslash; -#endif - if (!slash || slash == dir) return true; - *slash = '\0'; - return cbm_mkdir_p(dir, 0755); -} - -/* One non-blocking attempt, in-process and cross-process. */ -static cbm_zova_coord_status_t coord_try(const char *path, bool exclusive) { - cbm_mutex_lock(&coord_mutex); - coord_entry_t **found = coord_find(path); - if (found) { - coord_entry_t *e = *found; - cbm_zova_coord_status_t status = CBM_ZOVA_COORD_BUSY; - if (!exclusive && !e->exclusive) { - e->shared++; - status = CBM_ZOVA_COORD_OK; - } - cbm_mutex_unlock(&coord_mutex); - return status; - } - coord_entry_t *e = calloc(1, sizeof(*e)); - if (!e) { - cbm_mutex_unlock(&coord_mutex); - return CBM_ZOVA_COORD_ERROR; - } - snprintf(e->path, sizeof(e->path), "%s", path); - int rc = cbm_file_lock_acquire( - path, exclusive ? CBM_FILE_LOCK_EXCLUSIVE : CBM_FILE_LOCK_SHARED, false, &e->lock); - if (rc != 0) { - free(e); - cbm_mutex_unlock(&coord_mutex); - return rc == CBM_FILE_LOCK_BUSY ? CBM_ZOVA_COORD_BUSY : CBM_ZOVA_COORD_ERROR; - } - e->exclusive = exclusive; - e->shared = exclusive ? 0 : 1; - e->next = coord_entries; - coord_entries = e; - cbm_mutex_unlock(&coord_mutex); - return CBM_ZOVA_COORD_OK; -} - -static int64_t coord_now_ms(void) { - struct timespec ts; - cbm_clock_gettime(CLOCK_MONOTONIC, &ts); - return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; -} - -/* Poll instead of blocking in the kernel so a wait stays bounded and returns - * promptly on shutdown. */ -static cbm_zova_coord_status_t coord_acquire(const char *path, bool exclusive, int64_t wait_ms, - int64_t deadline_ms, cbm_zova_coord_hold_t *out) { - out->held = false; - if (snprintf(out->path, sizeof(out->path), "%s", path) >= (int)sizeof(out->path) || - !coord_ensure_parent_dir(path)) - return CBM_ZOVA_COORD_ERROR; - coord_ensure_initialized(); - for (;;) { - if (atomic_load(&coord_cancelled)) return CBM_ZOVA_COORD_CANCELLED; - cbm_zova_coord_status_t status = coord_try(path, exclusive); - if (status == CBM_ZOVA_COORD_OK) { - out->held = true; - return status; - } - if (status != CBM_ZOVA_COORD_BUSY || wait_ms <= 0 || coord_now_ms() >= deadline_ms) - return status; - struct timespec pause = {.tv_sec = 0, .tv_nsec = COORD_POLL_MS * 1000000L}; - cbm_nanosleep(&pause, NULL); - } -} - -int64_t cbm_zova_coord_default_wait_ms(void) { - const char *raw = getenv("CBM_INDEX_LEASE_TIMEOUT_S"); - if (raw && raw[0]) { - char *end = NULL; - long long seconds = strtoll(raw, &end, 10); - if (end && *end == '\0' && seconds >= 0) return (int64_t)seconds * 1000; - } - return (int64_t)COORD_DEFAULT_WAIT_S * 1000; -} - -static int coord_lease_path(const char *zova_path, const char *workspace_id, char *out, - size_t out_size) { - if (!zova_path || !zova_path[0] || cbm_zova_workspace_id_validate(workspace_id) != 0) - return -1; - int n = snprintf(out, out_size, "%s.ws-%s.lock", zova_path, workspace_id); - return n > 0 && (size_t)n < out_size ? 0 : -1; -} - -static int coord_barrier_path(const char *zova_path, const char *suffix, char *out, - size_t out_size) { - if (!zova_path || !zova_path[0]) return -1; - int n = snprintf(out, out_size, "%s.%s", zova_path, suffix); - return n > 0 && (size_t)n < out_size ? 0 : -1; -} - -/* The barrier prefers writers. A writer holds the turnstile exclusive while it - * waits for the barrier; a reader passes the turnstile (shared, briefly) before - * taking the barrier shared. So once a restore or repack is waiting, no new - * index run starts, and the runs already going drain instead of overlapping - * forever. */ -static cbm_zova_coord_status_t coord_barrier_acquire(const char *zova_path, bool exclusive, - int64_t wait_ms, int64_t deadline_ms, - cbm_zova_coord_hold_t *out) { - out->held = false; - char turnstile_path[4096], barrier_path[4096]; - if (coord_barrier_path(zova_path, "prepare.turnstile.lock", turnstile_path, - sizeof(turnstile_path)) != 0 || - coord_barrier_path(zova_path, "prepare.lock", barrier_path, sizeof(barrier_path)) != 0) - return CBM_ZOVA_COORD_ERROR; - cbm_zova_coord_hold_t turnstile; - cbm_zova_coord_status_t status = - coord_acquire(turnstile_path, exclusive, wait_ms, deadline_ms, &turnstile); - if (status != CBM_ZOVA_COORD_OK) { - snprintf(out->path, sizeof(out->path), "%s", barrier_path); - return status; - } - status = coord_acquire(barrier_path, exclusive, wait_ms, deadline_ms, out); - cbm_zova_coord_release(&turnstile); - return status; -} - -cbm_zova_coord_status_t cbm_zova_workspace_lease_acquire(const char *zova_path, - const char *workspace_id, - int64_t wait_ms, - cbm_zova_coord_hold_t *out) { - if (!out) return CBM_ZOVA_COORD_ERROR; - out->held = false; - char path[4096]; - if (coord_lease_path(zova_path, workspace_id, path, sizeof(path)) != 0) - return CBM_ZOVA_COORD_ERROR; - return coord_acquire(path, true, wait_ms, coord_now_ms() + wait_ms, out); -} - -cbm_zova_coord_status_t cbm_zova_prepare_barrier_acquire(const char *zova_path, bool exclusive, - int64_t wait_ms, - cbm_zova_coord_hold_t *out) { - if (!out) return CBM_ZOVA_COORD_ERROR; - out->held = false; - return coord_barrier_acquire(zova_path, exclusive, wait_ms, coord_now_ms() + wait_ms, out); -} - -void cbm_zova_coord_release(cbm_zova_coord_hold_t *hold) { - if (!hold || !hold->held) return; - hold->held = false; - coord_ensure_initialized(); - cbm_mutex_lock(&coord_mutex); - coord_entry_t **found = coord_find(hold->path); - if (found) { - coord_entry_t *e = *found; - if (e->exclusive || --e->shared <= 0) { - cbm_file_lock_release(&e->lock); - *found = e->next; - free(e); - } - } - cbm_mutex_unlock(&coord_mutex); -} - -cbm_zova_coord_status_t cbm_zova_index_hold_acquire(const char *root_path, int64_t wait_ms, - cbm_zova_index_hold_t *out, - char *out_workspace_id, - size_t out_workspace_id_size) { - if (!out) return CBM_ZOVA_COORD_ERROR; - memset(out, 0, sizeof(*out)); - char zova_path[4096]; - char workspace_id[CBM_ZOVA_WORKSPACE_ID_MAX]; - if (cbm_zova_user_database_path(zova_path, sizeof(zova_path)) != 0 || - cbm_zova_workspace_id_for_root(root_path, workspace_id, sizeof(workspace_id)) != 0) - return CBM_ZOVA_COORD_ERROR; - if (out_workspace_id && out_workspace_id_size > 0) - snprintf(out_workspace_id, out_workspace_id_size, "%s", workspace_id); - int64_t deadline_ms = coord_now_ms() + wait_ms; - char path[4096]; - cbm_zova_coord_status_t status = - coord_barrier_acquire(zova_path, false, wait_ms, deadline_ms, &out->barrier); - if (status != CBM_ZOVA_COORD_OK) { - out->barrier_busy = status == CBM_ZOVA_COORD_BUSY; - return status; - } - if (coord_lease_path(zova_path, workspace_id, path, sizeof(path)) != 0) { - cbm_zova_coord_release(&out->barrier); - return CBM_ZOVA_COORD_ERROR; - } - status = coord_acquire(path, true, wait_ms, deadline_ms, &out->lease); - if (status != CBM_ZOVA_COORD_OK) cbm_zova_coord_release(&out->barrier); - return status; -} - -void cbm_zova_index_hold_release(cbm_zova_index_hold_t *hold) { - if (!hold) return; - cbm_zova_coord_release(&hold->lease); - cbm_zova_coord_release(&hold->barrier); -} - -void cbm_zova_coord_set_cancelled(bool cancelled) { - atomic_store(&coord_cancelled, cancelled); -} diff --git a/src/zova/cbm_zova_coordination.zig b/src/zova/cbm_zova_coordination.zig new file mode 100644 index 00000000..b842751a --- /dev/null +++ b/src/zova/cbm_zova_coordination.zig @@ -0,0 +1,246 @@ +//! cbm_zova_coordination.zig — Indexing coordination around one shared +//! cbm.zova: the workspace lease and the preparation barrier. +//! +//! Implements src/zova/cbm_zova_coordination.h; the header documents the lock +//! order and what each lock protects. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); +const interop = @import("../foundation/interop.zig"); +const compat_thread = @import("../foundation/compat_thread.zig"); + +const Str = interop.Str; +const Status = c.cbm_zova_coord_status_t; +const Hold = c.cbm_zova_coord_hold_t; +const IndexHold = c.cbm_zova_index_hold_t; + +const is_windows = builtin.os.tag == .windows; + +const COORD_POLL_MS = 20; +const COORD_DEFAULT_WAIT_S = 300; +const COORD_PATH_MAX = 4096; + +/// In-process accounting. A file lock alone cannot exclude threads of one +/// process, and a second descriptor on the same file would drop the process's +/// lock when closed. So each lock file is held at most once per process, and +/// this table records who in the process holds it. +const CoordEntry = struct { + next: ?*CoordEntry, + path: [COORD_PATH_MAX]u8, + shared: c_int, // in-process shared holders + exclusive: bool, + lock: c.cbm_file_lock_t, +}; + +var coord_entries: ?*CoordEntry = null; +var coord_mutex: c.cbm_mutex_t = undefined; +var coord_cancelled = std.atomic.Value(bool).init(false); + +var coord_once: c.cbm_once_t = compat_thread.once_init; + +fn coordInit() callconv(.c) void { + c.cbm_mutex_init(&coord_mutex); +} + +fn coordEnsureInitialized() void { + c.cbm_once(&coord_once, coordInit); +} + +/// The link that points at the entry for `path`, so the caller can unlink it. +fn coordFind(path: []const u8) ?*?*CoordEntry { + var link: *?*CoordEntry = &coord_entries; + while (link.*) |entry| : (link = &entry.next) { + if (std.mem.eql(u8, std.mem.sliceTo(&entry.path, 0), path)) return link; + } + return null; +} + +/// The cache directory may not exist before the first index run. +fn coordEnsureParentDir(path: []const u8) bool { + var dir: [COORD_PATH_MAX]u8 = undefined; + if (path.len >= dir.len) return false; + @memcpy(dir[0..path.len], path); + dir[path.len] = 0; + var slash = std.mem.lastIndexOfScalar(u8, path, '/'); + if (is_windows) { + const backslash = std.mem.lastIndexOfScalar(u8, path, '\\'); + if (slash == null or (backslash != null and backslash.? > slash.?)) slash = backslash; + } + const at = slash orelse return true; + if (at == 0) return true; + dir[at] = 0; + return c.cbm_mkdir_p(&dir, 0o755); +} + +/// One non-blocking attempt, in-process and cross-process. +fn coordTry(path: Str, exclusive: bool) Status { + c.cbm_mutex_lock(&coord_mutex); + defer c.cbm_mutex_unlock(&coord_mutex); + const p = std.mem.span(path); + if (coordFind(p)) |found| { + const e = found.*.?; + if (!exclusive and !e.exclusive) { + e.shared += 1; + return c.CBM_ZOVA_COORD_OK; + } + return c.CBM_ZOVA_COORD_BUSY; + } + const fresh: ?*CoordEntry = interop.callocN(CoordEntry, 1); + const e = fresh orelse return c.CBM_ZOVA_COORD_ERROR; + _ = interop.bprint(&e.path, "{s}", .{p}); + const rc = c.cbm_file_lock_acquire(path, if (exclusive) c.CBM_FILE_LOCK_EXCLUSIVE else c.CBM_FILE_LOCK_SHARED, false, &e.lock); + if (rc != 0) { + c.free(e); + return if (rc == c.CBM_FILE_LOCK_BUSY) c.CBM_ZOVA_COORD_BUSY else c.CBM_ZOVA_COORD_ERROR; + } + e.exclusive = exclusive; + e.shared = if (exclusive) 0 else 1; + e.next = coord_entries; + coord_entries = e; + return c.CBM_ZOVA_COORD_OK; +} + +fn coordNowMs() i64 { + var ts: c.struct_timespec = undefined; + _ = c.cbm_clock_gettime(c.CLOCK_MONOTONIC, &ts); + return @as(i64, ts.tv_sec) * 1000 + @divTrunc(@as(i64, ts.tv_nsec), 1000000); +} + +/// Poll instead of blocking in the kernel so a wait stays bounded and returns +/// promptly on shutdown. +fn coordAcquire(path: Str, exclusive: bool, wait_ms: i64, deadline_ms: i64, out: *Hold) Status { + out.held = false; + const p = std.mem.span(path); + if (interop.bprint(&out.path, "{s}", .{p}) >= out.path.len or !coordEnsureParentDir(p)) + return c.CBM_ZOVA_COORD_ERROR; + coordEnsureInitialized(); + while (true) { + if (coord_cancelled.load(.seq_cst)) return c.CBM_ZOVA_COORD_CANCELLED; + const status = coordTry(path, exclusive); + if (status == c.CBM_ZOVA_COORD_OK) { + out.held = true; + return status; + } + if (status != c.CBM_ZOVA_COORD_BUSY or wait_ms <= 0 or coordNowMs() >= deadline_ms) + return status; + var pause = std.mem.zeroes(c.struct_timespec); + pause.tv_nsec = COORD_POLL_MS * 1000000; + _ = c.cbm_nanosleep(&pause, null); + } +} + +pub export fn cbm_zova_coord_default_wait_ms() i64 { + const raw: [*c]const u8 = c.getenv("CBM_INDEX_LEASE_TIMEOUT_S"); + if (raw != null and raw[0] != 0) { + var end: [*c]u8 = null; + const seconds = c.strtoll(raw, &end, 10); + if (end != null and end[0] == 0 and seconds >= 0) return @as(i64, seconds) *% 1000; + } + return COORD_DEFAULT_WAIT_S * 1000; +} + +fn coordLeasePath(zova_path: ?Str, workspace_id: ?Str, out: []u8) c_int { + const path = zova_path orelse return -1; + if (path[0] == 0 or c.cbm_zova_workspace_id_validate(workspace_id) != 0) return -1; + const n = interop.bprint(out, "{s}.ws-{s}.lock", .{ std.mem.span(path), std.mem.span(workspace_id.?) }); + return if (n > 0 and n < out.len) 0 else -1; +} + +fn coordBarrierPath(zova_path: ?Str, suffix: []const u8, out: []u8) c_int { + const path = zova_path orelse return -1; + if (path[0] == 0) return -1; + const n = interop.bprint(out, "{s}.{s}", .{ std.mem.span(path), suffix }); + return if (n > 0 and n < out.len) 0 else -1; +} + +/// The barrier prefers writers. A writer holds the turnstile exclusive while it +/// waits for the barrier; a reader passes the turnstile (shared, briefly) before +/// taking the barrier shared. So once a restore or repack is waiting, no new +/// index run starts, and the runs already going drain instead of overlapping +/// forever. +fn coordBarrierAcquire(zova_path: ?Str, exclusive: bool, wait_ms: i64, deadline_ms: i64, out: *Hold) Status { + out.held = false; + var turnstile_path: [COORD_PATH_MAX]u8 = undefined; + var barrier_path: [COORD_PATH_MAX]u8 = undefined; + if (coordBarrierPath(zova_path, "prepare.turnstile.lock", &turnstile_path) != 0 or + coordBarrierPath(zova_path, "prepare.lock", &barrier_path) != 0) + return c.CBM_ZOVA_COORD_ERROR; + var turnstile: Hold = undefined; + var status = coordAcquire(@ptrCast(&turnstile_path), exclusive, wait_ms, deadline_ms, &turnstile); + if (status != c.CBM_ZOVA_COORD_OK) { + _ = interop.bprint(&out.path, "{s}", .{std.mem.sliceTo(&barrier_path, 0)}); + return status; + } + status = coordAcquire(@ptrCast(&barrier_path), exclusive, wait_ms, deadline_ms, out); + cbm_zova_coord_release(&turnstile); + return status; +} + +pub export fn cbm_zova_workspace_lease_acquire(zova_path: ?Str, workspace_id: ?Str, wait_ms: i64, out: ?*Hold) Status { + const hold = out orelse return c.CBM_ZOVA_COORD_ERROR; + hold.held = false; + var path: [COORD_PATH_MAX]u8 = undefined; + if (coordLeasePath(zova_path, workspace_id, &path) != 0) return c.CBM_ZOVA_COORD_ERROR; + return coordAcquire(@ptrCast(&path), true, wait_ms, coordNowMs() +| wait_ms, hold); +} + +pub export fn cbm_zova_prepare_barrier_acquire(zova_path: ?Str, exclusive: bool, wait_ms: i64, out: ?*Hold) Status { + const hold = out orelse return c.CBM_ZOVA_COORD_ERROR; + hold.held = false; + return coordBarrierAcquire(zova_path, exclusive, wait_ms, coordNowMs() +| wait_ms, hold); +} + +pub export fn cbm_zova_coord_release(hold: ?*Hold) void { + const h = hold orelse return; + if (!h.held) return; + h.held = false; + coordEnsureInitialized(); + c.cbm_mutex_lock(&coord_mutex); + defer c.cbm_mutex_unlock(&coord_mutex); + const found = coordFind(std.mem.sliceTo(&h.path, 0)) orelse return; + const e = found.*.?; + if (!e.exclusive) e.shared -= 1; + if (e.exclusive or e.shared <= 0) { + c.cbm_file_lock_release(&e.lock); + found.* = e.next; + c.free(e); + } +} + +pub export fn cbm_zova_index_hold_acquire(root_path: ?Str, wait_ms: i64, out: ?*IndexHold, out_workspace_id: [*c]u8, out_workspace_id_size: usize) Status { + const hold = out orelse return c.CBM_ZOVA_COORD_ERROR; + hold.* = std.mem.zeroes(IndexHold); + var zova_path: [COORD_PATH_MAX]u8 = undefined; + var workspace_id: [c.CBM_ZOVA_WORKSPACE_ID_MAX]u8 = undefined; + if (c.cbm_zova_user_database_path(&zova_path, zova_path.len) != 0 or + c.cbm_zova_workspace_id_for_root(root_path, &workspace_id, workspace_id.len) != 0) + return c.CBM_ZOVA_COORD_ERROR; + if (out_workspace_id != null and out_workspace_id_size > 0) + _ = interop.bprint(out_workspace_id[0..out_workspace_id_size], "{s}", .{std.mem.sliceTo(&workspace_id, 0)}); + const deadline_ms = coordNowMs() +| wait_ms; + var path: [COORD_PATH_MAX]u8 = undefined; + var status = coordBarrierAcquire(@ptrCast(&zova_path), false, wait_ms, deadline_ms, &hold.barrier); + if (status != c.CBM_ZOVA_COORD_OK) { + hold.barrier_busy = status == c.CBM_ZOVA_COORD_BUSY; + return status; + } + if (coordLeasePath(@ptrCast(&zova_path), @ptrCast(&workspace_id), &path) != 0) { + cbm_zova_coord_release(&hold.barrier); + return c.CBM_ZOVA_COORD_ERROR; + } + status = coordAcquire(@ptrCast(&path), true, wait_ms, deadline_ms, &hold.lease); + if (status != c.CBM_ZOVA_COORD_OK) cbm_zova_coord_release(&hold.barrier); + return status; +} + +pub export fn cbm_zova_index_hold_release(hold: ?*IndexHold) void { + const h = hold orelse return; + cbm_zova_coord_release(&h.lease); + cbm_zova_coord_release(&h.barrier); +} + +/// Async-signal-safe: a single atomic store. +pub export fn cbm_zova_coord_set_cancelled(cancelled: bool) void { + coord_cancelled.store(cancelled, .seq_cst); +} diff --git a/src/zova/cbm_zova_delta.c b/src/zova/cbm_zova_delta.c deleted file mode 100644 index fbf6b478..00000000 --- a/src/zova/cbm_zova_delta.c +++ /dev/null @@ -1,559 +0,0 @@ -#include "cbm_zova_delta.h" - -#include "foundation/compat.h" - -#include -#include -#include - -struct cbm_zova_workspace_delta { - const cbm_zova_publish_node_t **node_inserts; - const cbm_zova_publish_node_t **node_updates; - char **node_deletes; - const cbm_zova_publish_edge_t **edge_inserts; - cbm_zova_delta_edge_delete_t *edge_deletes; - cbm_zova_delta_topology_edge_t *topology_inserts; - cbm_zova_delta_topology_edge_t *topology_deletes; - const cbm_zova_publish_node_vector_t **node_vector_upserts; - char **node_vector_deletes; - const cbm_zova_publish_token_vector_t **token_vector_upserts; - char **token_vector_deletes; - const cbm_zova_file_hash_input_t **file_hash_upserts; - char **file_hash_deletes; - bool replace_summary; - const cbm_zova_project_summary_input_t *summary; - int64_t expected_generation; - cbm_zova_workspace_delta_metrics_t metrics; -}; - -static int text_equal(const char *left, const char *right) { - return strcmp(left ? left : "", right ? right : "") == 0; -} - -static char *delta_dup(const char *value) { - if (!value) return NULL; - size_t length = strlen(value); - char *copy = malloc(length + 1); - if (copy) memcpy(copy, value, length + 1); - return copy; -} - -static double delta_elapsed_ms(struct timespec started) { - struct timespec finished; - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - return (double)(finished.tv_sec - started.tv_sec) * 1000.0 + - (double)(finished.tv_nsec - started.tv_nsec) / 1000000.0; -} - -static int sorted_unique(char *const *ids, int count) { - if (count < 0 || (count > 0 && !ids)) return 0; - for (int i = 0; i < count; i++) { - if (!ids[i] || (i > 0 && strcmp(ids[i - 1], ids[i]) >= 0)) return 0; - } - return 1; -} - -enum { DELTA_EDGE_ID_CAP = CBM_ZOVA_WORKSPACE_ID_MAX + 80 }; - -static int snapshot_edge_id_at(const cbm_zova_workspace_snapshot_t *snapshot, int index, - char out[DELTA_EDGE_ID_CAP]) { - return cbm_zova_workspace_snapshot_format_edge_id( - snapshot, index, out, DELTA_EDGE_ID_CAP); -} - -static int snapshot_edge_ids_sorted_unique(const cbm_zova_workspace_snapshot_t *snapshot) { - if (!snapshot || snapshot->edge_count < 0 || - (snapshot->edge_count > 0 && (!snapshot->edges || !snapshot->edge_ids))) return 0; - char previous[DELTA_EDGE_ID_CAP] = {0}; - char current[DELTA_EDGE_ID_CAP] = {0}; - for (int i = 0; i < snapshot->edge_count; i++) { - if (snapshot_edge_id_at(snapshot, i, current) != 0 || - (i > 0 && strcmp(previous, current) >= 0)) return 0; - memcpy(previous, current, sizeof(previous)); - } - return 1; -} - -static int digest_valid(const char digest[CBM_ZOVA_DIGEST_HEX_SIZE]) { - if (!digest || strlen(digest) != CBM_ZOVA_DIGEST_HEX_SIZE - 1) return 0; - for (size_t i = 0; i < CBM_ZOVA_DIGEST_HEX_SIZE - 1; i++) { - unsigned char c = (unsigned char)digest[i]; - if (!isdigit(c) && !(c >= 'a' && c <= 'f')) return 0; - } - return 1; -} - -int cbm_zova_workspace_delta_required_components( - const cbm_zova_workspace_snapshot_t *before, - const cbm_zova_publish_model_t *after, - cbm_zova_snapshot_components_t *out_components) { - if (!before || !after || !out_components) return -1; - const cbm_zova_workspace_generation_result_t *digests = - cbm_zova_publish_model_digests(after); - if (!digests || !digest_valid(before->integrity.topology_sha256) || - !digest_valid(before->integrity.node_vector_sha256) || - !digest_valid(before->integrity.token_vector_sha256) || - !digest_valid(digests->topology_sha256) || - !digest_valid(digests->node_vector_sha256) || - !digest_valid(digests->token_vector_sha256)) return -1; - cbm_zova_snapshot_components_t components = CBM_ZOVA_SNAPSHOT_COMPONENT_NONE; - if (strcmp(before->integrity.topology_sha256, digests->topology_sha256) != 0) - components |= CBM_ZOVA_SNAPSHOT_COMPONENT_TOPOLOGY; - if (strcmp(before->integrity.node_vector_sha256, digests->node_vector_sha256) != 0) - components |= CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS; - if (strcmp(before->integrity.token_vector_sha256, digests->token_vector_sha256) != 0) - components |= CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS; - *out_components = components; - return 0; -} - -static int node_equal(const cbm_zova_workspace_snapshot_t *before, int before_index, - const cbm_zova_publish_node_t *after) { - const CBMDumpNode *left = &before->nodes[before_index]; - const CBMDumpNode *right = after->source; - return text_equal(left->project, right->project) && text_equal(left->label, right->label) && - text_equal(left->name, right->name) && - text_equal(left->qualified_name, right->qualified_name) && - text_equal(left->file_path, right->file_path) && - left->start_line == right->start_line && left->end_line == right->end_line && - text_equal(left->properties, right->properties); -} - -static const char *snapshot_edge_endpoint(const cbm_zova_workspace_snapshot_t *snapshot, - int64_t dump_id) { - return dump_id > 0 && dump_id <= snapshot->node_count - ? snapshot->node_stable_ids[dump_id - 1] - : NULL; -} - -static int edge_equal(const cbm_zova_workspace_snapshot_t *before, int before_index, - const cbm_zova_publish_edge_t *after) { - const CBMDumpEdge *left = &before->edges[before_index]; - const CBMDumpEdge *right = after->source; - return text_equal(snapshot_edge_endpoint(before, left->source_id), - after->source_stable_id) && - text_equal(snapshot_edge_endpoint(before, left->target_id), - after->target_stable_id) && - text_equal(left->project, right->project) && text_equal(left->type, right->type) && - text_equal(left->properties, right->properties) && - text_equal(left->url_path, right->url_path) && - text_equal(left->local_name, right->local_name); -} - -static int topology_compare(const char *left_source, const char *left_type, - const char *left_target, const char *right_source, - const char *right_type, const char *right_target) { - int order = strcmp(left_source, right_source); - if (order == 0) order = strcmp(left_type, right_type); - if (order == 0) order = strcmp(left_target, right_target); - return order; -} - -static int vector_equal(const CBMDumpVector *left, const CBMDumpVector *right) { - return text_equal(left->project, right->project) && left->vector_len == right->vector_len && - left->vector_len >= 0 && - (left->vector_len == 0 || - (left->vector && right->vector && - memcmp(left->vector, right->vector, (size_t)left->vector_len) == 0)); -} - -static int token_vector_equal(const CBMDumpTokenVec *left, const CBMDumpTokenVec *right) { - return text_equal(left->project, right->project) && text_equal(left->token, right->token) && - left->idf == right->idf && left->vector_len == right->vector_len && - left->vector_len >= 0 && - (left->vector_len == 0 || - (left->vector && right->vector && - memcmp(left->vector, right->vector, (size_t)left->vector_len) == 0)); -} - -static int hash_equal(const cbm_zova_file_hash_input_t *left, - const cbm_zova_file_hash_input_t *right) { - return text_equal(left->file_path, right->file_path) && - text_equal(left->content_hash, right->content_hash) && - left->mtime_ns == right->mtime_ns && left->size_bytes == right->size_bytes; -} - -static int summary_equal(const cbm_zova_project_summary_input_t *left, - const cbm_zova_project_summary_input_t *right) { - return left->present == right->present && - (!left->present || - (text_equal(left->summary, right->summary) && - text_equal(left->source_hash, right->source_hash) && - text_equal(left->created_at, right->created_at) && - text_equal(left->updated_at, right->updated_at))); -} - -static int allocate_arrays(cbm_zova_workspace_delta_t *delta, - const cbm_zova_workspace_snapshot_t *before, - const cbm_zova_publish_model_t *after, - cbm_zova_snapshot_components_t required) { -#define DELTA_ALLOC(field, count, type) \ - do { \ - delta->field = (count) > 0 ? calloc((size_t)(count), sizeof(type)) : NULL; \ - if ((count) > 0 && !delta->field) return -1; \ - } while (0) - DELTA_ALLOC(node_inserts, cbm_zova_publish_model_node_count(after), *delta->node_inserts); - DELTA_ALLOC(node_updates, cbm_zova_publish_model_node_count(after), *delta->node_updates); - DELTA_ALLOC(node_deletes, before->node_count, *delta->node_deletes); - DELTA_ALLOC(edge_inserts, cbm_zova_publish_model_edge_count(after), *delta->edge_inserts); - DELTA_ALLOC(edge_deletes, before->edge_count + cbm_zova_publish_model_edge_count(after), - *delta->edge_deletes); - DELTA_ALLOC(topology_inserts, - (required & CBM_ZOVA_SNAPSHOT_COMPONENT_TOPOLOGY) ? - cbm_zova_publish_model_topology_count(after) : 0, - *delta->topology_inserts); - DELTA_ALLOC(topology_deletes, - (required & CBM_ZOVA_SNAPSHOT_COMPONENT_TOPOLOGY) ? - before->topology_edge_count : 0, - *delta->topology_deletes); - DELTA_ALLOC(node_vector_upserts, - (required & CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS) ? - cbm_zova_publish_model_node_vector_count(after) : 0, - *delta->node_vector_upserts); - DELTA_ALLOC(node_vector_deletes, - (required & CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS) ? - before->node_vector_count : 0, - *delta->node_vector_deletes); - DELTA_ALLOC(token_vector_upserts, - (required & CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS) ? - cbm_zova_publish_model_token_vector_count(after) : 0, - *delta->token_vector_upserts); - DELTA_ALLOC(token_vector_deletes, - (required & CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS) ? - before->token_vector_count : 0, - *delta->token_vector_deletes); - DELTA_ALLOC(file_hash_upserts, cbm_zova_publish_model_file_hash_count(after), - *delta->file_hash_upserts); - DELTA_ALLOC(file_hash_deletes, before->file_hash_count, *delta->file_hash_deletes); -#undef DELTA_ALLOC - return 0; -} - -static int append_delete(char **values, uint64_t *count, const char *id) { - char *copy = delta_dup(id); - if (!copy) return -1; - values[(*count)++] = copy; - return 0; -} - -static int build_nodes(cbm_zova_workspace_delta_t *delta, - const cbm_zova_workspace_snapshot_t *before, - cbm_zova_publish_model_t *after) { - uint64_t next_source_ordinal = 0; - for (int i = 0; i < before->node_count; i++) { - if (before->node_source_ordinals[i] == UINT64_MAX) return -1; - if (before->node_source_ordinals[i] >= next_source_ordinal) - next_source_ordinal = before->node_source_ordinals[i] + 1; - } - int left = 0, right = 0, right_count = cbm_zova_publish_model_node_count(after); - while (left < before->node_count || right < right_count) { - const cbm_zova_publish_node_t *node = - right < right_count ? cbm_zova_publish_model_node_at(after, right) : NULL; - int order = left >= before->node_count ? 1 : right >= right_count ? -1 - : strcmp(before->node_stable_ids[left], node->stable_id); - if (order < 0) { - if (append_delete(delta->node_deletes, &delta->metrics.node_deletes, - before->node_stable_ids[left++]) != 0) return -1; - } else if (order > 0) { - if (next_source_ordinal == UINT64_MAX || - cbm_zova_publish_model_set_node_source_ordinal( - after, right, next_source_ordinal++) != 0) return -1; - node = cbm_zova_publish_model_node_at(after, right); - delta->node_inserts[delta->metrics.node_inserts++] = node; - right++; - } else { - bool equal = node_equal(before, left, node); - uint64_t old_ordinal = before->node_source_ordinals[left]; - uint64_t desired_ordinal = old_ordinal; - if (!equal && node->source_ordinal != old_ordinal) { - if (next_source_ordinal == UINT64_MAX) return -1; - desired_ordinal = next_source_ordinal++; - } - if (cbm_zova_publish_model_set_node_source_ordinal( - after, right, desired_ordinal) != 0) return -1; - node = cbm_zova_publish_model_node_at(after, right); - if (!equal) - delta->node_updates[delta->metrics.node_updates++] = node; - left++; right++; - } - } - return 0; -} - -static int build_edges(cbm_zova_workspace_delta_t *delta, - const cbm_zova_workspace_snapshot_t *before, - const cbm_zova_publish_model_t *after) { - int left = 0, right = 0, right_count = cbm_zova_publish_model_edge_count(after); - while (left < before->edge_count || right < right_count) { - char before_edge_id[DELTA_EDGE_ID_CAP] = {0}; - if (left < before->edge_count && - snapshot_edge_id_at(before, left, before_edge_id) != 0) return -1; - const cbm_zova_publish_edge_t *edge = - right < right_count ? cbm_zova_publish_model_edge_at(after, right) : NULL; - int order = left >= before->edge_count ? 1 : right >= right_count ? -1 - : strcmp(before_edge_id, edge->edge_id); - if (order < 0) { - const CBMDumpEdge *old = &before->edges[left]; - cbm_zova_delta_edge_delete_t *deleted = - &delta->edge_deletes[delta->metrics.edge_deletes++]; - deleted->edge_id = delta_dup(before_edge_id); - deleted->source_stable_id = delta_dup(before->node_stable_ids[old->source_id - 1]); - deleted->edge_type = delta_dup(old->type); - deleted->target_stable_id = delta_dup(before->node_stable_ids[old->target_id - 1]); - deleted->local_name = delta_dup(old->local_name ? old->local_name : ""); - if (!deleted->edge_id || !deleted->source_stable_id || !deleted->edge_type || - !deleted->target_stable_id || !deleted->local_name) return -1; - left++; - } else if (order > 0) { - delta->edge_inserts[delta->metrics.edge_inserts++] = edge; - right++; - } else { - if (!edge_equal(before, left, edge)) { - const CBMDumpEdge *old = &before->edges[left]; - cbm_zova_delta_edge_delete_t *deleted = - &delta->edge_deletes[delta->metrics.edge_deletes++]; - deleted->edge_id = delta_dup(before_edge_id); - deleted->source_stable_id = delta_dup(before->node_stable_ids[old->source_id - 1]); - deleted->edge_type = delta_dup(old->type); - deleted->target_stable_id = delta_dup(before->node_stable_ids[old->target_id - 1]); - deleted->local_name = delta_dup(old->local_name ? old->local_name : ""); - if (!deleted->edge_id || !deleted->source_stable_id || !deleted->edge_type || - !deleted->target_stable_id || !deleted->local_name) return -1; - delta->edge_inserts[delta->metrics.edge_inserts++] = edge; - } - left++; right++; - } - } - return 0; -} - -static int build_topology(cbm_zova_workspace_delta_t *delta, - const cbm_zova_workspace_snapshot_t *before, - const cbm_zova_publish_model_t *after) { - int left = 0, right = 0, right_count = cbm_zova_publish_model_topology_count(after); - while (left < before->topology_edge_count || right < right_count) { - const cbm_zova_publish_edge_t *edge = right < right_count - ? cbm_zova_publish_model_topology_at(after, right) : NULL; - const cbm_zova_snapshot_topology_edge_t *old = left < before->topology_edge_count - ? &before->topology_edges[left] : NULL; - int order = left >= before->topology_edge_count ? 1 : right >= right_count ? -1 - : topology_compare(old->source_stable_id, old->edge_type, old->target_stable_id, - edge->source_stable_id, edge->source->type, - edge->target_stable_id); - if (order < 0) { - cbm_zova_delta_topology_edge_t *row = - &delta->topology_deletes[delta->metrics.topology_deletes++]; - row->source_stable_id = delta_dup(old->source_stable_id); - row->edge_type = delta_dup(old->edge_type); - row->target_stable_id = delta_dup(old->target_stable_id); - if (!row->source_stable_id || !row->edge_type || !row->target_stable_id) return -1; - left++; - } else if (order > 0) { - delta->topology_inserts[delta->metrics.topology_inserts++] = - (cbm_zova_delta_topology_edge_t){ - .source_stable_id=edge->source_stable_id, - .edge_type=edge->source->type, - .target_stable_id=edge->target_stable_id}; - right++; - } else { - left++; right++; - } - } - return 0; -} - -static int build_node_vectors(cbm_zova_workspace_delta_t *delta, - const cbm_zova_workspace_snapshot_t *before, - const cbm_zova_publish_model_t *after) { - int left = 0, right = 0, right_count = cbm_zova_publish_model_node_vector_count(after); - while (left < before->node_vector_count || right < right_count) { - const cbm_zova_publish_node_vector_t *vector = right < right_count - ? cbm_zova_publish_model_node_vector_at(after, right) : NULL; - int order = left >= before->node_vector_count ? 1 : right >= right_count ? -1 - : strcmp(before->node_vector_ids[left], vector->stable_id); - if (order < 0) { - if (append_delete(delta->node_vector_deletes, - &delta->metrics.node_vector_deletes, - before->node_vector_ids[left++]) != 0) return -1; - } else if (order > 0) { - delta->node_vector_upserts[delta->metrics.node_vector_upserts++] = vector; - right++; - } else { - if (!vector_equal(&before->node_vectors[left], vector->source)) - delta->node_vector_upserts[delta->metrics.node_vector_upserts++] = vector; - left++; right++; - } - } - return 0; -} - -static int build_token_vectors(cbm_zova_workspace_delta_t *delta, - const cbm_zova_workspace_snapshot_t *before, - const cbm_zova_publish_model_t *after) { - int left = 0, right = 0, right_count = cbm_zova_publish_model_token_vector_count(after); - while (left < before->token_vector_count || right < right_count) { - const cbm_zova_publish_token_vector_t *vector = right < right_count - ? cbm_zova_publish_model_token_vector_at(after, right) : NULL; - int order = left >= before->token_vector_count ? 1 : right >= right_count ? -1 - : strcmp(before->token_vector_ids[left], vector->token_id); - if (order < 0) { - if (append_delete(delta->token_vector_deletes, - &delta->metrics.token_vector_deletes, - before->token_vectors[left++].token) != 0) return -1; - } else if (order > 0) { - delta->token_vector_upserts[delta->metrics.token_vector_upserts++] = vector; - right++; - } else { - if (!token_vector_equal(&before->token_vectors[left], vector->source)) - delta->token_vector_upserts[delta->metrics.token_vector_upserts++] = vector; - left++; right++; - } - } - return 0; -} - -static int build_hashes(cbm_zova_workspace_delta_t *delta, - const cbm_zova_workspace_snapshot_t *before, - const cbm_zova_publish_model_t *after) { - int left = 0, right = 0, right_count = cbm_zova_publish_model_file_hash_count(after); - while (left < before->file_hash_count || right < right_count) { - const cbm_zova_file_hash_input_t *hash = right < right_count - ? cbm_zova_publish_model_file_hash_at(after, right) : NULL; - int order = left >= before->file_hash_count ? 1 : right >= right_count ? -1 - : strcmp(before->file_hashes[left].file_path, hash->file_path); - if (order < 0) { - if (append_delete(delta->file_hash_deletes, &delta->metrics.file_hash_deletes, - before->file_hashes[left++].file_path) != 0) return -1; - } else if (order > 0) { - delta->file_hash_upserts[delta->metrics.file_hash_upserts++] = hash; - right++; - } else { - if (!hash_equal(&before->file_hashes[left], hash)) - delta->file_hash_upserts[delta->metrics.file_hash_upserts++] = hash; - left++; right++; - } - } - return 0; -} - -int cbm_zova_workspace_delta_build(const cbm_zova_workspace_snapshot_t *before, - cbm_zova_publish_model_t *after, - cbm_zova_workspace_delta_t **out_delta) { - if (!before || !after || !out_delta) return -1; - *out_delta = NULL; - cbm_zova_snapshot_components_t required = CBM_ZOVA_SNAPSHOT_COMPONENT_NONE; - if (before->generation <= 0 || - cbm_zova_workspace_delta_required_components(before, after, &required) != 0 || - (before->hydrated_components & required) != required || - (before->node_count > 0 && !before->node_source_ordinals) || - !sorted_unique(before->node_stable_ids, before->node_count) || - !snapshot_edge_ids_sorted_unique(before) || - ((required & CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS) && - !sorted_unique(before->node_vector_ids, before->node_vector_count)) || - ((required & CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS) && - !sorted_unique(before->token_vector_ids, before->token_vector_count))) return -1; - struct timespec started; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - cbm_zova_workspace_delta_t *delta = calloc(1, sizeof(*delta)); - if (!delta || allocate_arrays(delta, before, after, required) != 0 || - build_nodes(delta, before, after) != 0 || build_edges(delta, before, after) != 0 || - ((required & CBM_ZOVA_SNAPSHOT_COMPONENT_TOPOLOGY) && - build_topology(delta, before, after) != 0) || - ((required & CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS) && - build_node_vectors(delta, before, after) != 0) || - ((required & CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS) && - build_token_vectors(delta, before, after) != 0) || - build_hashes(delta, before, after) != 0) { - cbm_zova_workspace_delta_free(delta); - return -1; - } - const cbm_zova_workspace_generation_input_t *input = cbm_zova_publish_model_input(after); - delta->summary = &input->project_summary; - delta->expected_generation = before->generation; - delta->replace_summary = !summary_equal(&before->project_summary, delta->summary); - delta->metrics.summary_replacements = delta->replace_summary ? 1 : 0; - delta->metrics.diff_ms = delta_elapsed_ms(started); - *out_delta = delta; - return 0; -} - -int64_t cbm_zova_workspace_delta_expected_generation( - const cbm_zova_workspace_delta_t *delta) { - return delta ? delta->expected_generation : 0; -} - -void cbm_zova_workspace_delta_free(cbm_zova_workspace_delta_t *delta) { - if (!delta) return; - for (uint64_t i = 0; i < delta->metrics.node_deletes; i++) free(delta->node_deletes[i]); - for (uint64_t i = 0; i < delta->metrics.edge_deletes; i++) { - free((char *)delta->edge_deletes[i].edge_id); - free((char *)delta->edge_deletes[i].source_stable_id); - free((char *)delta->edge_deletes[i].edge_type); - free((char *)delta->edge_deletes[i].target_stable_id); - free((char *)delta->edge_deletes[i].local_name); - } - for (uint64_t i = 0; i < delta->metrics.topology_deletes; i++) { - free((char *)delta->topology_deletes[i].source_stable_id); - free((char *)delta->topology_deletes[i].edge_type); - free((char *)delta->topology_deletes[i].target_stable_id); - } - for (uint64_t i = 0; i < delta->metrics.node_vector_deletes; i++) free(delta->node_vector_deletes[i]); - for (uint64_t i = 0; i < delta->metrics.token_vector_deletes; i++) free(delta->token_vector_deletes[i]); - for (uint64_t i = 0; i < delta->metrics.file_hash_deletes; i++) free(delta->file_hash_deletes[i]); - free(delta->node_inserts); free(delta->node_updates); free(delta->node_deletes); - free(delta->edge_inserts); free(delta->edge_deletes); - free(delta->topology_inserts); free(delta->topology_deletes); - free(delta->node_vector_upserts); free(delta->node_vector_deletes); - free(delta->token_vector_upserts); free(delta->token_vector_deletes); - free(delta->file_hash_upserts); free(delta->file_hash_deletes); - free(delta); -} - -void cbm_zova_workspace_delta_metrics(const cbm_zova_workspace_delta_t *delta, - cbm_zova_workspace_delta_metrics_t *out) { - if (out) *out = delta ? delta->metrics : (cbm_zova_workspace_delta_metrics_t){0}; -} - -#define DELTA_GETTER(name, type, field, count_field) \ - type cbm_zova_workspace_delta_##name##_at(const cbm_zova_workspace_delta_t *delta, int index) { \ - return delta && index >= 0 && (uint64_t)index < delta->metrics.count_field \ - ? delta->field[index] : NULL; \ - } -DELTA_GETTER(node_insert, const cbm_zova_publish_node_t *, node_inserts, node_inserts) -DELTA_GETTER(node_update, const cbm_zova_publish_node_t *, node_updates, node_updates) -DELTA_GETTER(node_delete, const char *, node_deletes, node_deletes) -DELTA_GETTER(edge_insert, const cbm_zova_publish_edge_t *, edge_inserts, edge_inserts) -DELTA_GETTER(node_vector_upsert, const cbm_zova_publish_node_vector_t *, node_vector_upserts, node_vector_upserts) -DELTA_GETTER(node_vector_delete, const char *, node_vector_deletes, node_vector_deletes) -DELTA_GETTER(token_vector_upsert, const cbm_zova_publish_token_vector_t *, token_vector_upserts, token_vector_upserts) -DELTA_GETTER(token_vector_delete, const char *, token_vector_deletes, token_vector_deletes) -DELTA_GETTER(file_hash_upsert, const cbm_zova_file_hash_input_t *, file_hash_upserts, file_hash_upserts) -DELTA_GETTER(file_hash_delete, const char *, file_hash_deletes, file_hash_deletes) -#undef DELTA_GETTER - -const cbm_zova_delta_edge_delete_t *cbm_zova_workspace_delta_edge_delete_at( - const cbm_zova_workspace_delta_t *delta, int index) { - return delta && index >= 0 && (uint64_t)index < delta->metrics.edge_deletes - ? &delta->edge_deletes[index] : NULL; -} - -const cbm_zova_delta_topology_edge_t *cbm_zova_workspace_delta_topology_insert_at( - const cbm_zova_workspace_delta_t *delta, int index) { - return delta && index >= 0 && (uint64_t)index < delta->metrics.topology_inserts - ? &delta->topology_inserts[index] : NULL; -} - -const cbm_zova_delta_topology_edge_t *cbm_zova_workspace_delta_topology_delete_at( - const cbm_zova_workspace_delta_t *delta, int index) { - return delta && index >= 0 && (uint64_t)index < delta->metrics.topology_deletes - ? &delta->topology_deletes[index] : NULL; -} - -bool cbm_zova_workspace_delta_replaces_summary(const cbm_zova_workspace_delta_t *delta) { - return delta && delta->replace_summary; -} - -const cbm_zova_project_summary_input_t *cbm_zova_workspace_delta_summary( - const cbm_zova_workspace_delta_t *delta) { - return delta ? delta->summary : NULL; -} diff --git a/src/zova/cbm_zova_delta.zig b/src/zova/cbm_zova_delta.zig new file mode 100644 index 00000000..9ede9b7c --- /dev/null +++ b/src/zova/cbm_zova_delta.zig @@ -0,0 +1,628 @@ +//! cbm_zova_delta.zig — Exact difference between a committed workspace +//! snapshot and the publication model of the next generation. +//! +//! Implements src/zova/cbm_zova_delta.h. Both sides are sorted by stable id, +//! so every section is one ordered merge: rows only in the snapshot become +//! deletes, rows only in the model become inserts, and rows in both are +//! compared field by field. + +const std = @import("std"); +const c = @import("c"); +const interop = @import("../foundation/interop.zig"); + +const CStr = interop.CStr; +const Snapshot = c.cbm_zova_workspace_snapshot_t; +const Model = c.cbm_zova_publish_model_t; +const Components = c.cbm_zova_snapshot_components_t; +const Metrics = c.cbm_zova_workspace_delta_metrics_t; +const PublishNode = c.cbm_zova_publish_node_t; +const PublishEdge = c.cbm_zova_publish_edge_t; +const PublishNodeVector = c.cbm_zova_publish_node_vector_t; +const PublishTokenVector = c.cbm_zova_publish_token_vector_t; +const FileHash = c.cbm_zova_file_hash_input_t; +const Summary = c.cbm_zova_project_summary_input_t; +const EdgeDelete = c.cbm_zova_delta_edge_delete_t; +const TopologyEdge = c.cbm_zova_delta_topology_edge_t; + +const TOPOLOGY: Components = c.CBM_ZOVA_SNAPSHOT_COMPONENT_TOPOLOGY; +const NODE_VECTORS: Components = c.CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS; +const TOKEN_VECTORS: Components = c.CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS; + +/// Arrays are allocated at their upper bound; the counts live in `metrics`. +/// Insert/upsert rows borrow from the model, delete rows are owned copies. +const Delta = struct { + node_inserts: [*c][*c]const PublishNode, + node_updates: [*c][*c]const PublishNode, + node_deletes: [*c][*c]u8, + edge_inserts: [*c][*c]const PublishEdge, + edge_deletes: [*c]EdgeDelete, + topology_inserts: [*c]TopologyEdge, + topology_deletes: [*c]TopologyEdge, + node_vector_upserts: [*c][*c]const PublishNodeVector, + node_vector_deletes: [*c][*c]u8, + token_vector_upserts: [*c][*c]const PublishTokenVector, + token_vector_deletes: [*c][*c]u8, + file_hash_upserts: [*c][*c]const FileHash, + file_hash_deletes: [*c][*c]u8, + replace_summary: bool, + summary: [*c]const Summary, + expected_generation: i64, + metrics: Metrics, +}; + +fn textEqual(left: CStr, right: CStr) bool { + return c.strcmp(if (left != null) left else "", if (right != null) right else "") == 0; +} + +const deltaDup = interop.heapStrdup; + +fn deltaElapsedMs(started: c.struct_timespec) f64 { + var finished: c.struct_timespec = undefined; + _ = c.cbm_clock_gettime(c.CLOCK_MONOTONIC, &finished); + return @as(f64, @floatFromInt(finished.tv_sec - started.tv_sec)) * 1000.0 + + @as(f64, @floatFromInt(finished.tv_nsec - started.tv_nsec)) / 1000000.0; +} + +fn sortedUnique(ids: [*c]const [*c]u8, count: c_int) bool { + if (count < 0 or (count > 0 and ids == null)) return false; + for (0..@intCast(count)) |i| { + if (ids[i] == null or (i > 0 and c.strcmp(ids[i - 1], ids[i]) >= 0)) return false; + } + return true; +} + +const DELTA_EDGE_ID_CAP = c.CBM_ZOVA_WORKSPACE_ID_MAX + 80; +const EdgeId = [DELTA_EDGE_ID_CAP]u8; + +fn snapshotEdgeIdAt(snapshot: *const Snapshot, index: c_int, out: *EdgeId) c_int { + return c.cbm_zova_workspace_snapshot_format_edge_id(snapshot, index, out, out.len); +} + +fn snapshotEdgeIdsSortedUnique(snapshot: *const Snapshot) bool { + if (snapshot.edge_count < 0 or + (snapshot.edge_count > 0 and (snapshot.edges == null or snapshot.edge_ids == null))) return false; + var previous: EdgeId = @splat(0); + var current: EdgeId = @splat(0); + var i: c_int = 0; + while (i < snapshot.edge_count) : (i += 1) { + if (snapshotEdgeIdAt(snapshot, i, ¤t) != 0 or + (i > 0 and c.strcmp(&previous, ¤t) >= 0)) return false; + previous = current; + } + return true; +} + +fn digestValid(digest: *const [c.CBM_ZOVA_DIGEST_HEX_SIZE]u8) bool { + const text = std.mem.sliceTo(digest, 0); + if (text.len != c.CBM_ZOVA_DIGEST_HEX_SIZE - 1) return false; + for (text) |ch| { + if (!std.ascii.isDigit(ch) and !(ch >= 'a' and ch <= 'f')) return false; + } + return true; +} + +pub export fn cbm_zova_workspace_delta_required_components(before: ?*const Snapshot, after: ?*const Model, out_components: ?*Components) c_int { + const b = before orelse return -1; + const out = out_components orelse return -1; + if (after == null) return -1; + const digests_ptr: ?*const c.cbm_zova_workspace_generation_result_t = c.cbm_zova_publish_model_digests(after); + const digests = digests_ptr orelse return -1; + if (!digestValid(&b.integrity.topology_sha256) or + !digestValid(&b.integrity.node_vector_sha256) or + !digestValid(&b.integrity.token_vector_sha256) or + !digestValid(&digests.topology_sha256) or + !digestValid(&digests.node_vector_sha256) or + !digestValid(&digests.token_vector_sha256)) return -1; + var components: Components = c.CBM_ZOVA_SNAPSHOT_COMPONENT_NONE; + if (c.strcmp(&b.integrity.topology_sha256, &digests.topology_sha256) != 0) + components |= TOPOLOGY; + if (c.strcmp(&b.integrity.node_vector_sha256, &digests.node_vector_sha256) != 0) + components |= NODE_VECTORS; + if (c.strcmp(&b.integrity.token_vector_sha256, &digests.token_vector_sha256) != 0) + components |= TOKEN_VECTORS; + out.* = components; + return 0; +} + +fn nodeEqual(before: *const Snapshot, before_index: usize, after: *const PublishNode) bool { + const left = &before.nodes[before_index]; + const right = after.source; + return textEqual(left.project, right.*.project) and textEqual(left.label, right.*.label) and + textEqual(left.name, right.*.name) and + textEqual(left.qualified_name, right.*.qualified_name) and + textEqual(left.file_path, right.*.file_path) and + left.start_line == right.*.start_line and left.end_line == right.*.end_line and + textEqual(left.properties, right.*.properties); +} + +fn snapshotEdgeEndpoint(snapshot: *const Snapshot, dump_id: i64) CStr { + return if (dump_id > 0 and dump_id <= snapshot.node_count) + snapshot.node_stable_ids[@intCast(dump_id - 1)] + else + null; +} + +fn edgeEqual(before: *const Snapshot, before_index: usize, after: *const PublishEdge) bool { + const left = &before.edges[before_index]; + const right = after.source; + return textEqual(snapshotEdgeEndpoint(before, left.source_id), after.source_stable_id) and + textEqual(snapshotEdgeEndpoint(before, left.target_id), after.target_stable_id) and + textEqual(left.project, right.*.project) and textEqual(left.type, right.*.type) and + textEqual(left.properties, right.*.properties) and + textEqual(left.url_path, right.*.url_path) and + textEqual(left.local_name, right.*.local_name); +} + +fn topologyCompare(left_source: CStr, left_type: CStr, left_target: CStr, right_source: CStr, right_type: CStr, right_target: CStr) c_int { + var order = c.strcmp(left_source, right_source); + if (order == 0) order = c.strcmp(left_type, right_type); + if (order == 0) order = c.strcmp(left_target, right_target); + return order; +} + +fn blobEqual(left: [*c]const u8, right: [*c]const u8, len: c_int) bool { + if (len < 0) return false; + if (len == 0) return true; + if (left == null or right == null) return false; + const n: usize = @intCast(len); + return std.mem.eql(u8, left[0..n], right[0..n]); +} + +fn vectorEqual(left: *const c.CBMDumpVector, right: *const c.CBMDumpVector) bool { + return textEqual(left.project, right.project) and left.vector_len == right.vector_len and + blobEqual(left.vector, right.vector, left.vector_len); +} + +fn tokenVectorEqual(left: *const c.CBMDumpTokenVec, right: *const c.CBMDumpTokenVec) bool { + return textEqual(left.project, right.project) and textEqual(left.token, right.token) and + left.idf == right.idf and left.vector_len == right.vector_len and + blobEqual(left.vector, right.vector, left.vector_len); +} + +fn hashEqual(left: *const FileHash, right: *const FileHash) bool { + return textEqual(left.file_path, right.file_path) and + textEqual(left.content_hash, right.content_hash) and + left.mtime_ns == right.mtime_ns and left.size_bytes == right.size_bytes; +} + +fn summaryEqual(left: *const Summary, right: *const Summary) bool { + return left.present == right.present and + (!left.present or + (textEqual(left.summary, right.summary) and + textEqual(left.source_hash, right.source_hash) and + textEqual(left.created_at, right.created_at) and + textEqual(left.updated_at, right.updated_at))); +} + +/// A zeroed array of `count` rows; NULL (and no error) for count <= 0. +fn deltaAlloc(comptime T: type, count: c_int) error{OutOfMemory}![*c]T { + if (count <= 0) return null; + const items = interop.callocN(T, @intCast(count)); + if (items == null) return error.OutOfMemory; + return items; +} + +fn allocateArrays(delta: *Delta, before: *const Snapshot, after: *const Model, required: Components) error{OutOfMemory}!void { + const node_count = c.cbm_zova_publish_model_node_count(after); + const edge_count = c.cbm_zova_publish_model_edge_count(after); + const topology = required & TOPOLOGY != 0; + const node_vectors = required & NODE_VECTORS != 0; + const token_vectors = required & TOKEN_VECTORS != 0; + delta.node_inserts = try deltaAlloc([*c]const PublishNode, node_count); + delta.node_updates = try deltaAlloc([*c]const PublishNode, node_count); + delta.node_deletes = try deltaAlloc([*c]u8, before.node_count); + delta.edge_inserts = try deltaAlloc([*c]const PublishEdge, edge_count); + delta.edge_deletes = try deltaAlloc(EdgeDelete, before.edge_count +| edge_count); + delta.topology_inserts = try deltaAlloc(TopologyEdge, if (topology) c.cbm_zova_publish_model_topology_count(after) else 0); + delta.topology_deletes = try deltaAlloc(TopologyEdge, if (topology) before.topology_edge_count else 0); + delta.node_vector_upserts = try deltaAlloc([*c]const PublishNodeVector, if (node_vectors) c.cbm_zova_publish_model_node_vector_count(after) else 0); + delta.node_vector_deletes = try deltaAlloc([*c]u8, if (node_vectors) before.node_vector_count else 0); + delta.token_vector_upserts = try deltaAlloc([*c]const PublishTokenVector, if (token_vectors) c.cbm_zova_publish_model_token_vector_count(after) else 0); + delta.token_vector_deletes = try deltaAlloc([*c]u8, if (token_vectors) before.token_vector_count else 0); + delta.file_hash_upserts = try deltaAlloc([*c]const FileHash, c.cbm_zova_publish_model_file_hash_count(after)); + delta.file_hash_deletes = try deltaAlloc([*c]u8, before.file_hash_count); +} + +fn appendDelete(values: [*c][*c]u8, count: *u64, id: CStr) c_int { + const copy = deltaDup(id); + if (copy == null) return -1; + values[count.*] = copy; + count.* += 1; + return 0; +} + +/// Which side of an ordered merge comes first: <0 the snapshot row (left), +/// >0 the model row (right), 0 both carry the same key. +fn mergeOrder(left: c_int, left_count: c_int, right: c_int, right_count: c_int, left_key: CStr, right_key: CStr) c_int { + if (left >= left_count) return 1; + if (right >= right_count) return -1; + return c.strcmp(left_key, right_key); +} + +fn buildNodes(delta: *Delta, before: *const Snapshot, after: *Model) c_int { + const no_ordinal = std.math.maxInt(u64); + var next_source_ordinal: u64 = 0; + for (0..@intCast(before.node_count)) |i| { + const ordinal = before.node_source_ordinals[i]; + if (ordinal == no_ordinal) return -1; + if (ordinal >= next_source_ordinal) next_source_ordinal = ordinal + 1; + } + var left: c_int = 0; + var right: c_int = 0; + const right_count = c.cbm_zova_publish_model_node_count(after); + while (left < before.node_count or right < right_count) { + var node: [*c]const PublishNode = if (right < right_count) c.cbm_zova_publish_model_node_at(after, right) else null; + const li: usize = @intCast(left); + const order = mergeOrder(left, before.node_count, right, right_count, if (left < before.node_count) before.node_stable_ids[li] else null, if (node != null) node.*.stable_id else null); + if (order < 0) { + if (appendDelete(delta.node_deletes, &delta.metrics.node_deletes, before.node_stable_ids[li]) != 0) return -1; + left += 1; + } else if (order > 0) { + if (next_source_ordinal == no_ordinal or + c.cbm_zova_publish_model_set_node_source_ordinal(after, right, next_source_ordinal) != 0) return -1; + next_source_ordinal += 1; + node = c.cbm_zova_publish_model_node_at(after, right); + delta.node_inserts[delta.metrics.node_inserts] = node; + delta.metrics.node_inserts += 1; + right += 1; + } else { + const equal = nodeEqual(before, li, node); + const old_ordinal = before.node_source_ordinals[li]; + var desired_ordinal = old_ordinal; + if (!equal and node.*.source_ordinal != old_ordinal) { + if (next_source_ordinal == no_ordinal) return -1; + desired_ordinal = next_source_ordinal; + next_source_ordinal += 1; + } + if (c.cbm_zova_publish_model_set_node_source_ordinal(after, right, desired_ordinal) != 0) return -1; + node = c.cbm_zova_publish_model_node_at(after, right); + if (!equal) { + delta.node_updates[delta.metrics.node_updates] = node; + delta.metrics.node_updates += 1; + } + left += 1; + right += 1; + } + } + return 0; +} + +fn appendEdgeDelete(delta: *Delta, before: *const Snapshot, index: usize, edge_id: *const EdgeId) c_int { + const old = &before.edges[index]; + const deleted = &delta.edge_deletes[delta.metrics.edge_deletes]; + delta.metrics.edge_deletes += 1; + deleted.edge_id = deltaDup(edge_id); + deleted.source_stable_id = deltaDup(snapshotEdgeEndpoint(before, old.source_id)); + deleted.edge_type = deltaDup(old.type); + deleted.target_stable_id = deltaDup(snapshotEdgeEndpoint(before, old.target_id)); + deleted.local_name = deltaDup(if (old.local_name != null) old.local_name else ""); + return if (deleted.edge_id == null or deleted.source_stable_id == null or deleted.edge_type == null or + deleted.target_stable_id == null or deleted.local_name == null) -1 else 0; +} + +fn buildEdges(delta: *Delta, before: *const Snapshot, after: *const Model) c_int { + var left: c_int = 0; + var right: c_int = 0; + const right_count = c.cbm_zova_publish_model_edge_count(after); + while (left < before.edge_count or right < right_count) { + var before_edge_id: EdgeId = @splat(0); + if (left < before.edge_count and snapshotEdgeIdAt(before, left, &before_edge_id) != 0) return -1; + const edge: [*c]const PublishEdge = if (right < right_count) c.cbm_zova_publish_model_edge_at(after, right) else null; + const li: usize = @intCast(left); + const order = mergeOrder(left, before.edge_count, right, right_count, &before_edge_id, if (edge != null) edge.*.edge_id else null); + if (order < 0) { + if (appendEdgeDelete(delta, before, li, &before_edge_id) != 0) return -1; + left += 1; + } else if (order > 0) { + delta.edge_inserts[delta.metrics.edge_inserts] = edge; + delta.metrics.edge_inserts += 1; + right += 1; + } else { + if (!edgeEqual(before, li, edge)) { + if (appendEdgeDelete(delta, before, li, &before_edge_id) != 0) return -1; + delta.edge_inserts[delta.metrics.edge_inserts] = edge; + delta.metrics.edge_inserts += 1; + } + left += 1; + right += 1; + } + } + return 0; +} + +fn buildTopology(delta: *Delta, before: *const Snapshot, after: *const Model) c_int { + var left: c_int = 0; + var right: c_int = 0; + const right_count = c.cbm_zova_publish_model_topology_count(after); + while (left < before.topology_edge_count or right < right_count) { + const edge: [*c]const PublishEdge = if (right < right_count) c.cbm_zova_publish_model_topology_at(after, right) else null; + const old: [*c]const c.cbm_zova_snapshot_topology_edge_t = if (left < before.topology_edge_count) &before.topology_edges[@intCast(left)] else null; + const order: c_int = if (left >= before.topology_edge_count) + 1 + else if (right >= right_count) + -1 + else + topologyCompare(old.*.source_stable_id, old.*.edge_type, old.*.target_stable_id, edge.*.source_stable_id, edge.*.source.*.type, edge.*.target_stable_id); + if (order < 0) { + const row = &delta.topology_deletes[delta.metrics.topology_deletes]; + delta.metrics.topology_deletes += 1; + row.source_stable_id = deltaDup(old.*.source_stable_id); + row.edge_type = deltaDup(old.*.edge_type); + row.target_stable_id = deltaDup(old.*.target_stable_id); + if (row.source_stable_id == null or row.edge_type == null or row.target_stable_id == null) return -1; + left += 1; + } else if (order > 0) { + delta.topology_inserts[delta.metrics.topology_inserts] = .{ + .source_stable_id = edge.*.source_stable_id, + .edge_type = edge.*.source.*.type, + .target_stable_id = edge.*.target_stable_id, + }; + delta.metrics.topology_inserts += 1; + right += 1; + } else { + left += 1; + right += 1; + } + } + return 0; +} + +fn buildNodeVectors(delta: *Delta, before: *const Snapshot, after: *const Model) c_int { + var left: c_int = 0; + var right: c_int = 0; + const right_count = c.cbm_zova_publish_model_node_vector_count(after); + while (left < before.node_vector_count or right < right_count) { + const vector: [*c]const PublishNodeVector = if (right < right_count) c.cbm_zova_publish_model_node_vector_at(after, right) else null; + const li: usize = @intCast(left); + const order = mergeOrder(left, before.node_vector_count, right, right_count, if (left < before.node_vector_count) before.node_vector_ids[li] else null, if (vector != null) vector.*.stable_id else null); + if (order < 0) { + if (appendDelete(delta.node_vector_deletes, &delta.metrics.node_vector_deletes, before.node_vector_ids[li]) != 0) return -1; + left += 1; + } else if (order > 0) { + delta.node_vector_upserts[delta.metrics.node_vector_upserts] = vector; + delta.metrics.node_vector_upserts += 1; + right += 1; + } else { + if (!vectorEqual(before.node_vectors + li, vector.*.source)) { + delta.node_vector_upserts[delta.metrics.node_vector_upserts] = vector; + delta.metrics.node_vector_upserts += 1; + } + left += 1; + right += 1; + } + } + return 0; +} + +fn buildTokenVectors(delta: *Delta, before: *const Snapshot, after: *const Model) c_int { + var left: c_int = 0; + var right: c_int = 0; + const right_count = c.cbm_zova_publish_model_token_vector_count(after); + while (left < before.token_vector_count or right < right_count) { + const vector: [*c]const PublishTokenVector = if (right < right_count) c.cbm_zova_publish_model_token_vector_at(after, right) else null; + const li: usize = @intCast(left); + const order = mergeOrder(left, before.token_vector_count, right, right_count, if (left < before.token_vector_count) before.token_vector_ids[li] else null, if (vector != null) vector.*.token_id else null); + if (order < 0) { + if (appendDelete(delta.token_vector_deletes, &delta.metrics.token_vector_deletes, before.token_vectors[li].token) != 0) return -1; + left += 1; + } else if (order > 0) { + delta.token_vector_upserts[delta.metrics.token_vector_upserts] = vector; + delta.metrics.token_vector_upserts += 1; + right += 1; + } else { + if (!tokenVectorEqual(before.token_vectors + li, vector.*.source)) { + delta.token_vector_upserts[delta.metrics.token_vector_upserts] = vector; + delta.metrics.token_vector_upserts += 1; + } + left += 1; + right += 1; + } + } + return 0; +} + +fn buildHashes(delta: *Delta, before: *const Snapshot, after: *const Model) c_int { + var left: c_int = 0; + var right: c_int = 0; + const right_count = c.cbm_zova_publish_model_file_hash_count(after); + while (left < before.file_hash_count or right < right_count) { + const hash: [*c]const FileHash = if (right < right_count) c.cbm_zova_publish_model_file_hash_at(after, right) else null; + const li: usize = @intCast(left); + const order = mergeOrder(left, before.file_hash_count, right, right_count, if (left < before.file_hash_count) before.file_hashes[li].file_path else null, if (hash != null) hash.*.file_path else null); + if (order < 0) { + if (appendDelete(delta.file_hash_deletes, &delta.metrics.file_hash_deletes, before.file_hashes[li].file_path) != 0) return -1; + left += 1; + } else if (order > 0) { + delta.file_hash_upserts[delta.metrics.file_hash_upserts] = hash; + delta.metrics.file_hash_upserts += 1; + right += 1; + } else { + if (!hashEqual(before.file_hashes + li, hash)) { + delta.file_hash_upserts[delta.metrics.file_hash_upserts] = hash; + delta.metrics.file_hash_upserts += 1; + } + left += 1; + right += 1; + } + } + return 0; +} + +fn buildAll(delta: *Delta, before: *const Snapshot, after: *Model, required: Components) c_int { + allocateArrays(delta, before, after, required) catch return -1; + if (buildNodes(delta, before, after) != 0 or buildEdges(delta, before, after) != 0) return -1; + if (required & TOPOLOGY != 0 and buildTopology(delta, before, after) != 0) return -1; + if (required & NODE_VECTORS != 0 and buildNodeVectors(delta, before, after) != 0) return -1; + if (required & TOKEN_VECTORS != 0 and buildTokenVectors(delta, before, after) != 0) return -1; + return buildHashes(delta, before, after); +} + +pub export fn cbm_zova_workspace_delta_build(before: ?*const Snapshot, after: ?*Model, out_delta: ?*?*Delta) c_int { + const b = before orelse return -1; + const a = after orelse return -1; + const out = out_delta orelse return -1; + out.* = null; + var required: Components = c.CBM_ZOVA_SNAPSHOT_COMPONENT_NONE; + if (b.generation <= 0 or + cbm_zova_workspace_delta_required_components(b, a, &required) != 0 or + (b.hydrated_components & required) != required or + (b.node_count > 0 and b.node_source_ordinals == null) or + !sortedUnique(b.node_stable_ids, b.node_count) or + !snapshotEdgeIdsSortedUnique(b) or + (required & NODE_VECTORS != 0 and !sortedUnique(b.node_vector_ids, b.node_vector_count)) or + (required & TOKEN_VECTORS != 0 and !sortedUnique(b.token_vector_ids, b.token_vector_count))) return -1; + var started: c.struct_timespec = undefined; + _ = c.cbm_clock_gettime(c.CLOCK_MONOTONIC, &started); + const delta_ptr: ?*Delta = interop.callocN(Delta, 1); + const delta = delta_ptr orelse return -1; + if (buildAll(delta, b, a, required) != 0) { + cbm_zova_workspace_delta_free(delta); + return -1; + } + const input: [*c]const c.cbm_zova_workspace_generation_input_t = c.cbm_zova_publish_model_input(a); + delta.summary = &input.*.project_summary; + delta.expected_generation = b.generation; + delta.replace_summary = !summaryEqual(&b.project_summary, delta.summary); + delta.metrics.summary_replacements = if (delta.replace_summary) 1 else 0; + delta.metrics.diff_ms = deltaElapsedMs(started); + out.* = delta; + return 0; +} + +pub export fn cbm_zova_workspace_delta_expected_generation(delta: ?*const Delta) i64 { + return if (delta) |d| d.expected_generation else 0; +} + +fn freeConst(p: anytype) void { + c.free(@ptrCast(@constCast(p))); +} + +fn freeStrings(values: [*c][*c]u8, count: u64) void { + for (0..count) |i| c.free(values[i]); +} + +pub export fn cbm_zova_workspace_delta_free(delta: ?*Delta) void { + const d = delta orelse return; + freeStrings(d.node_deletes, d.metrics.node_deletes); + for (0..d.metrics.edge_deletes) |i| { + const row = &d.edge_deletes[i]; + freeConst(row.edge_id); + freeConst(row.source_stable_id); + freeConst(row.edge_type); + freeConst(row.target_stable_id); + freeConst(row.local_name); + } + for (0..d.metrics.topology_deletes) |i| { + const row = &d.topology_deletes[i]; + freeConst(row.source_stable_id); + freeConst(row.edge_type); + freeConst(row.target_stable_id); + } + freeStrings(d.node_vector_deletes, d.metrics.node_vector_deletes); + freeStrings(d.token_vector_deletes, d.metrics.token_vector_deletes); + freeStrings(d.file_hash_deletes, d.metrics.file_hash_deletes); + freeConst(d.node_inserts); + freeConst(d.node_updates); + freeConst(d.node_deletes); + freeConst(d.edge_inserts); + freeConst(d.edge_deletes); + freeConst(d.topology_inserts); + freeConst(d.topology_deletes); + freeConst(d.node_vector_upserts); + freeConst(d.node_vector_deletes); + freeConst(d.token_vector_upserts); + freeConst(d.token_vector_deletes); + freeConst(d.file_hash_upserts); + freeConst(d.file_hash_deletes); + c.free(d); +} + +pub export fn cbm_zova_workspace_delta_metrics(delta: ?*const Delta, out_metrics: ?*Metrics) void { + const out = out_metrics orelse return; + out.* = if (delta) |d| d.metrics else std.mem.zeroes(Metrics); +} + +/// items[index], or NULL when the index is outside [0, count). +fn rowAt(items: anytype, count: u64, index: c_int) @typeInfo(@TypeOf(items)).pointer.child { + if (index < 0 or @as(u64, @intCast(index)) >= count) return null; + return items[@intCast(index)]; +} + +/// &items[index], or NULL when the index is outside [0, count). +fn rowPtrAt(comptime T: type, items: [*c]const T, count: u64, index: c_int) ?*const T { + if (index < 0 or @as(u64, @intCast(index)) >= count) return null; + return &items[@intCast(index)]; +} + +pub export fn cbm_zova_workspace_delta_node_insert_at(delta: ?*const Delta, index: c_int) [*c]const PublishNode { + const d = delta orelse return null; + return rowAt(d.node_inserts, d.metrics.node_inserts, index); +} + +pub export fn cbm_zova_workspace_delta_node_update_at(delta: ?*const Delta, index: c_int) [*c]const PublishNode { + const d = delta orelse return null; + return rowAt(d.node_updates, d.metrics.node_updates, index); +} + +pub export fn cbm_zova_workspace_delta_node_delete_at(delta: ?*const Delta, index: c_int) CStr { + const d = delta orelse return null; + return rowAt(d.node_deletes, d.metrics.node_deletes, index); +} + +pub export fn cbm_zova_workspace_delta_edge_insert_at(delta: ?*const Delta, index: c_int) [*c]const PublishEdge { + const d = delta orelse return null; + return rowAt(d.edge_inserts, d.metrics.edge_inserts, index); +} + +pub export fn cbm_zova_workspace_delta_node_vector_upsert_at(delta: ?*const Delta, index: c_int) [*c]const PublishNodeVector { + const d = delta orelse return null; + return rowAt(d.node_vector_upserts, d.metrics.node_vector_upserts, index); +} + +pub export fn cbm_zova_workspace_delta_node_vector_delete_at(delta: ?*const Delta, index: c_int) CStr { + const d = delta orelse return null; + return rowAt(d.node_vector_deletes, d.metrics.node_vector_deletes, index); +} + +pub export fn cbm_zova_workspace_delta_token_vector_upsert_at(delta: ?*const Delta, index: c_int) [*c]const PublishTokenVector { + const d = delta orelse return null; + return rowAt(d.token_vector_upserts, d.metrics.token_vector_upserts, index); +} + +pub export fn cbm_zova_workspace_delta_token_vector_delete_at(delta: ?*const Delta, index: c_int) CStr { + const d = delta orelse return null; + return rowAt(d.token_vector_deletes, d.metrics.token_vector_deletes, index); +} + +pub export fn cbm_zova_workspace_delta_file_hash_upsert_at(delta: ?*const Delta, index: c_int) [*c]const FileHash { + const d = delta orelse return null; + return rowAt(d.file_hash_upserts, d.metrics.file_hash_upserts, index); +} + +pub export fn cbm_zova_workspace_delta_file_hash_delete_at(delta: ?*const Delta, index: c_int) CStr { + const d = delta orelse return null; + return rowAt(d.file_hash_deletes, d.metrics.file_hash_deletes, index); +} + +pub export fn cbm_zova_workspace_delta_edge_delete_at(delta: ?*const Delta, index: c_int) ?*const EdgeDelete { + const d = delta orelse return null; + return rowPtrAt(EdgeDelete, d.edge_deletes, d.metrics.edge_deletes, index); +} + +pub export fn cbm_zova_workspace_delta_topology_insert_at(delta: ?*const Delta, index: c_int) ?*const TopologyEdge { + const d = delta orelse return null; + return rowPtrAt(TopologyEdge, d.topology_inserts, d.metrics.topology_inserts, index); +} + +pub export fn cbm_zova_workspace_delta_topology_delete_at(delta: ?*const Delta, index: c_int) ?*const TopologyEdge { + const d = delta orelse return null; + return rowPtrAt(TopologyEdge, d.topology_deletes, d.metrics.topology_deletes, index); +} + +pub export fn cbm_zova_workspace_delta_replaces_summary(delta: ?*const Delta) bool { + return if (delta) |d| d.replace_summary else false; +} + +pub export fn cbm_zova_workspace_delta_summary(delta: ?*const Delta) [*c]const Summary { + return if (delta) |d| d.summary else null; +} diff --git a/src/zova/cbm_zova_edge_payload.c b/src/zova/cbm_zova_edge_payload.c deleted file mode 100644 index 7b24ed84..00000000 --- a/src/zova/cbm_zova_edge_payload.c +++ /dev/null @@ -1,211 +0,0 @@ -#include "cbm_zova_edge_payload.h" - -#include -#include - -enum { - CBM_ZOVA_EDGE_PAYLOAD_VERSION = 1, - CBM_ZOVA_EDGE_PAYLOAD_PROPERTIES = 1u << 0, - CBM_ZOVA_EDGE_PAYLOAD_URL_PATH = 1u << 1, - CBM_ZOVA_EDGE_PAYLOAD_LOCAL_NAME = 1u << 2, - CBM_ZOVA_EDGE_PAYLOAD_ALL_FIELDS = - CBM_ZOVA_EDGE_PAYLOAD_PROPERTIES | - CBM_ZOVA_EDGE_PAYLOAD_URL_PATH | - CBM_ZOVA_EDGE_PAYLOAD_LOCAL_NAME, -}; - -static const uint8_t payload_default_properties[] = "{}"; -static const uint8_t payload_default_empty[] = ""; - -static const char *payload_properties(const CBMDumpEdge *edge) { - return edge && edge->properties ? edge->properties : "{}"; -} - -static const char *payload_url_path(const CBMDumpEdge *edge) { - return edge && edge->url_path ? edge->url_path : ""; -} - -static const char *payload_local_name(const CBMDumpEdge *edge) { - return edge && edge->local_name ? edge->local_name : ""; -} - -static int payload_is_default(const CBMDumpEdge *edge) { - return edge && strcmp(payload_properties(edge), "{}") == 0 && - payload_url_path(edge)[0] == '\0' && payload_local_name(edge)[0] == '\0'; -} - -static size_t payload_varint_size(size_t value) { - size_t size = 1; - while (value >= 0x80) { - value >>= 7; - size++; - } - return size; -} - -static int payload_add_size(size_t *total, size_t value) { - if (!total || value > SIZE_MAX - *total) return -1; - *total += value; - return 0; -} - -int cbm_zova_edge_payload_encoded_size( - const CBMDumpEdge *const *edges, size_t edge_count, size_t *out_size) { - if (!edges || edge_count == 0 || !out_size) return -1; - for (size_t i = 0; i < edge_count; i++) - if (!edges[i]) return -1; - if (edge_count == 1 && payload_is_default(edges[0])) { - *out_size = 0; - return 0; - } - - size_t size = 1; - if (payload_add_size(&size, payload_varint_size(edge_count)) != 0) return -1; - for (size_t i = 0; i < edge_count; i++) { - const char *fields[] = { - payload_properties(edges[i]), payload_url_path(edges[i]), - payload_local_name(edges[i]), - }; - if (payload_add_size(&size, 1) != 0) return -1; - for (size_t field = 0; field < 3; field++) { - int present = field == 0 ? strcmp(fields[field], "{}") != 0 - : fields[field][0] != '\0'; - if (!present) continue; - size_t length = strlen(fields[field]); - if (payload_add_size(&size, payload_varint_size(length)) != 0 || - payload_add_size(&size, length) != 0) return -1; - } - } - *out_size = size; - return 0; -} - -static uint8_t *payload_write_varint(uint8_t *cursor, size_t value) { - do { - uint8_t byte = (uint8_t)(value & 0x7f); - value >>= 7; - *cursor++ = value ? (uint8_t)(byte | 0x80) : byte; - } while (value); - return cursor; -} - -int cbm_zova_edge_payload_encode( - const CBMDumpEdge *const *edges, size_t edge_count, - uint8_t *out_payload, size_t payload_capacity, size_t *out_payload_len) { - size_t required = 0; - if (!out_payload_len || - cbm_zova_edge_payload_encoded_size(edges, edge_count, &required) != 0) - return -1; - if (required > payload_capacity || (required > 0 && !out_payload)) return -1; - *out_payload_len = required; - if (required == 0) return 0; - - uint8_t *cursor = out_payload; - *cursor++ = CBM_ZOVA_EDGE_PAYLOAD_VERSION; - cursor = payload_write_varint(cursor, edge_count); - for (size_t i = 0; i < edge_count; i++) { - const char *fields[] = { - payload_properties(edges[i]), payload_url_path(edges[i]), - payload_local_name(edges[i]), - }; - uint8_t flags = 0; - if (strcmp(fields[0], "{}") != 0) flags |= CBM_ZOVA_EDGE_PAYLOAD_PROPERTIES; - if (fields[1][0]) flags |= CBM_ZOVA_EDGE_PAYLOAD_URL_PATH; - if (fields[2][0]) flags |= CBM_ZOVA_EDGE_PAYLOAD_LOCAL_NAME; - *cursor++ = flags; - for (size_t field = 0; field < 3; field++) { - if ((flags & (1u << field)) == 0) continue; - size_t length = strlen(fields[field]); - cursor = payload_write_varint(cursor, length); - memcpy(cursor, fields[field], length); - cursor += length; - } - } - return (size_t)(cursor - out_payload) == required ? 0 : -1; -} - -static int payload_read_varint(const uint8_t **cursor, const uint8_t *end, - size_t *out_value) { - if (!cursor || !*cursor || !out_value) return -1; - size_t value = 0; - unsigned shift = 0; - size_t bytes = 0; - uint8_t byte = 0; - do { - if (*cursor == end || shift >= sizeof(size_t) * CHAR_BIT) return -1; - byte = *(*cursor)++; - if (shift == sizeof(size_t) * CHAR_BIT - 7 && - (byte & 0x7f) > (SIZE_MAX >> shift)) return -1; - value |= (size_t)(byte & 0x7f) << shift; - shift += 7; - bytes++; - } while (byte & 0x80); - if (bytes != payload_varint_size(value)) return -1; - *out_value = value; - return 0; -} - -static int payload_read_slice(const uint8_t **cursor, const uint8_t *end, - cbm_zova_edge_payload_slice_t *out_slice) { - size_t length = 0; - if (payload_read_varint(cursor, end, &length) != 0 || - length > (size_t)(end - *cursor)) return -1; - out_slice->data = *cursor; - out_slice->len = length; - *cursor += length; - return 0; -} - -static int payload_walk(const uint8_t *payload, size_t payload_len, - cbm_zova_edge_payload_visitor_t visitor, void *context, - size_t *out_edge_count) { - if (payload_len == 0) { - cbm_zova_edge_payload_record_t record = { - .properties = {payload_default_properties, 2}, - .url_path = {payload_default_empty, 0}, - .local_name = {payload_default_empty, 0}, - }; - if (visitor && visitor(&record, context) != 0) return -1; - if (out_edge_count) *out_edge_count = 1; - return 0; - } - if (!payload) return -1; - const uint8_t *cursor = payload; - const uint8_t *end = payload + payload_len; - if (*cursor++ != CBM_ZOVA_EDGE_PAYLOAD_VERSION) return -1; - size_t edge_count = 0; - if (payload_read_varint(&cursor, end, &edge_count) != 0 || edge_count == 0) - return -1; - for (size_t i = 0; i < edge_count; i++) { - if (cursor == end) return -1; - uint8_t flags = *cursor++; - if (flags & ~CBM_ZOVA_EDGE_PAYLOAD_ALL_FIELDS) return -1; - cbm_zova_edge_payload_record_t record = { - .properties = {payload_default_properties, 2}, - .url_path = {payload_default_empty, 0}, - .local_name = {payload_default_empty, 0}, - }; - cbm_zova_edge_payload_slice_t *fields[] = { - &record.properties, &record.url_path, &record.local_name, - }; - for (size_t field = 0; field < 3; field++) - if ((flags & (1u << field)) != 0 && - payload_read_slice(&cursor, end, fields[field]) != 0) return -1; - if (visitor && visitor(&record, context) != 0) return -1; - } - if (cursor != end) return -1; - if (out_edge_count) *out_edge_count = edge_count; - return 0; -} - -int cbm_zova_edge_payload_visit( - const uint8_t *payload, size_t payload_len, - cbm_zova_edge_payload_visitor_t visitor, void *context, - size_t *out_edge_count) { - size_t count = 0; - if (payload_walk(payload, payload_len, NULL, NULL, &count) != 0) return -1; - if (visitor && payload_walk(payload, payload_len, visitor, context, NULL) != 0) - return -1; - if (out_edge_count) *out_edge_count = count; - return 0; -} diff --git a/src/zova/cbm_zova_edge_payload.zig b/src/zova/cbm_zova_edge_payload.zig new file mode 100644 index 00000000..981afaf6 --- /dev/null +++ b/src/zova/cbm_zova_edge_payload.zig @@ -0,0 +1,195 @@ +//! cbm_zova_edge_payload.zig — Compact encoding of the per-edge text fields +//! (properties, url_path, local_name) stored beside a Zova edge. +//! +//! Implements src/zova/cbm_zova_edge_payload.h. Layout: one version byte, a +//! varint edge count, then per edge a flags byte and a length-prefixed slice +//! for each present field. A single edge with all-default fields encodes to +//! zero bytes. + +const std = @import("std"); +const c = @import("c"); + +const Edge = c.CBMDumpEdge; +const Slice = c.cbm_zova_edge_payload_slice_t; +const Record = c.cbm_zova_edge_payload_record_t; + +const PAYLOAD_VERSION: u8 = 1; +const PAYLOAD_PROPERTIES: u8 = 1 << 0; +const PAYLOAD_URL_PATH: u8 = 1 << 1; +const PAYLOAD_LOCAL_NAME: u8 = 1 << 2; +const PAYLOAD_ALL_FIELDS: u8 = PAYLOAD_PROPERTIES | PAYLOAD_URL_PATH | PAYLOAD_LOCAL_NAME; + +const default_record = Record{ + .properties = .{ .data = "{}", .len = 2 }, + .url_path = .{ .data = "", .len = 0 }, + .local_name = .{ .data = "", .len = 0 }, +}; + +/// properties, url_path and local_name with their defaults applied. +fn payloadFields(edge: *const Edge) [3][]const u8 { + return .{ + if (edge.properties != null) std.mem.span(edge.properties) else "{}", + if (edge.url_path != null) std.mem.span(edge.url_path) else "", + if (edge.local_name != null) std.mem.span(edge.local_name) else "", + }; +} + +fn fieldPresent(fields: [3][]const u8, field: usize) bool { + return if (field == 0) !std.mem.eql(u8, fields[0], "{}") else fields[field].len != 0; +} + +fn payloadIsDefault(edge: *const Edge) bool { + const fields = payloadFields(edge); + return !fieldPresent(fields, 0) and !fieldPresent(fields, 1) and !fieldPresent(fields, 2); +} + +fn payloadVarintSize(value: usize) usize { + var size: usize = 1; + var v = value; + while (v >= 0x80) { + v >>= 7; + size += 1; + } + return size; +} + +fn encodedSize(edges: []const [*c]const Edge) ?usize { + if (edges.len == 0) return null; + for (edges) |edge| { + if (edge == null) return null; + } + if (edges.len == 1 and payloadIsDefault(edges[0])) return 0; + + const add = std.math.add; + var size = add(usize, 1, payloadVarintSize(edges.len)) catch return null; + for (edges) |edge| { + const fields = payloadFields(edge); + size = add(usize, size, 1) catch return null; + for (fields, 0..) |text, field| { + if (!fieldPresent(fields, field)) continue; + size = add(usize, size, payloadVarintSize(text.len)) catch return null; + size = add(usize, size, text.len) catch return null; + } + } + return size; +} + +pub export fn cbm_zova_edge_payload_encoded_size(edges: [*c]const [*c]const Edge, edge_count: usize, out_size: ?*usize) c_int { + const out = out_size orelse return -1; + if (edges == null) return -1; + out.* = encodedSize(edges[0..edge_count]) orelse return -1; + return 0; +} + +fn payloadWriteVarint(out: []u8, at: usize, value: usize) usize { + var cursor = at; + var v = value; + while (true) { + const byte: u8 = @intCast(v & 0x7f); + v >>= 7; + out[cursor] = if (v != 0) byte | 0x80 else byte; + cursor += 1; + if (v == 0) return cursor; + } +} + +pub export fn cbm_zova_edge_payload_encode(edges: [*c]const [*c]const Edge, edge_count: usize, out_payload: [*c]u8, payload_capacity: usize, out_payload_len: ?*usize) c_int { + const out_len = out_payload_len orelse return -1; + if (edges == null) return -1; + const list = edges[0..edge_count]; + const required = encodedSize(list) orelse return -1; + if (required > payload_capacity or (required > 0 and out_payload == null)) return -1; + out_len.* = required; + if (required == 0) return 0; + + const out = out_payload[0..required]; + out[0] = PAYLOAD_VERSION; + var cursor = payloadWriteVarint(out, 1, edge_count); + for (list) |edge| { + const fields = payloadFields(edge); + var flags: u8 = 0; + for (0..3) |field| { + if (fieldPresent(fields, field)) flags |= @as(u8, 1) << @intCast(field); + } + out[cursor] = flags; + cursor += 1; + for (fields, 0..) |text, field| { + if (flags & (@as(u8, 1) << @intCast(field)) == 0) continue; + cursor = payloadWriteVarint(out, cursor, text.len); + @memcpy(out[cursor..][0..text.len], text); + cursor += text.len; + } + } + return if (cursor == required) 0 else -1; +} + +/// Reads one canonical varint off the front of `cursor`. +fn payloadReadVarint(cursor: *[]const u8) ?usize { + const bits = @bitSizeOf(usize); + var value: usize = 0; + var shift: u32 = 0; + var bytes: usize = 0; + while (true) { + if (cursor.len == 0 or shift >= bits) return null; + const byte = cursor.*[0]; + cursor.* = cursor.*[1..]; + const low: usize = byte & 0x7f; + if (shift == bits - 7 and low > (@as(usize, std.math.maxInt(usize)) >> @intCast(shift))) return null; + value |= low << @intCast(shift); + shift += 7; + bytes += 1; + if (byte & 0x80 == 0) break; + } + if (bytes != payloadVarintSize(value)) return null; + return value; +} + +fn payloadReadSlice(cursor: *[]const u8) ?Slice { + const length = payloadReadVarint(cursor) orelse return null; + if (length > cursor.len) return null; + const slice = Slice{ .data = cursor.ptr, .len = length }; + cursor.* = cursor.*[length..]; + return slice; +} + +fn payloadWalk(payload: [*c]const u8, payload_len: usize, visitor: c.cbm_zova_edge_payload_visitor_t, context: ?*anyopaque) ?usize { + if (payload_len == 0) { + const record = default_record; + if (visitor) |visit| { + if (visit(&record, context) != 0) return null; + } + return 1; + } + if (payload == null) return null; + var cursor: []const u8 = payload[0..payload_len]; + if (cursor[0] != PAYLOAD_VERSION) return null; + cursor = cursor[1..]; + const edge_count = payloadReadVarint(&cursor) orelse return null; + if (edge_count == 0) return null; + for (0..edge_count) |_| { + if (cursor.len == 0) return null; + const flags = cursor[0]; + cursor = cursor[1..]; + if (flags & ~PAYLOAD_ALL_FIELDS != 0) return null; + var record = default_record; + const fields = [3]*Slice{ &record.properties, &record.url_path, &record.local_name }; + for (fields, 0..) |slot, field| { + if (flags & (@as(u8, 1) << @intCast(field)) == 0) continue; + slot.* = payloadReadSlice(&cursor) orelse return null; + } + if (visitor) |visit| { + if (visit(&record, context) != 0) return null; + } + } + if (cursor.len != 0) return null; + return edge_count; +} + +/// Validates the whole payload first, so the visitor never sees a record of a +/// payload that turns out to be malformed. +pub export fn cbm_zova_edge_payload_visit(payload: [*c]const u8, payload_len: usize, visitor: c.cbm_zova_edge_payload_visitor_t, context: ?*anyopaque, out_edge_count: ?*usize) c_int { + const count = payloadWalk(payload, payload_len, null, null) orelse return -1; + if (visitor != null and payloadWalk(payload, payload_len, visitor, context) == null) return -1; + if (out_edge_count) |out| out.* = count; + return 0; +} diff --git a/src/zova/cbm_zova_route.c b/src/zova/cbm_zova_route.c deleted file mode 100644 index 4dc12205..00000000 --- a/src/zova/cbm_zova_route.c +++ /dev/null @@ -1,12 +0,0 @@ -#include "zova/cbm_zova_route.h" - -#include "foundation/platform.h" - -#include - -int cbm_zova_user_database_path(char *out, size_t out_size) { - const char *cache = cbm_resolve_cache_dir(); - if (!out || out_size == 0 || !cache || !cache[0]) return -1; - int n = snprintf(out, out_size, "%s/cbm.zova", cache); - return n > 0 && (size_t)n < out_size ? 0 : -1; -} diff --git a/src/zova/cbm_zova_route.zig b/src/zova/cbm_zova_route.zig new file mode 100644 index 00000000..b57696c2 --- /dev/null +++ b/src/zova/cbm_zova_route.zig @@ -0,0 +1,14 @@ +//! cbm_zova_route.zig — Location of the user-wide cbm.zova database. +//! +//! Implements src/zova/cbm_zova_route.h. + +const std = @import("std"); +const c = @import("c"); +const interop = @import("../foundation/interop.zig"); + +pub export fn cbm_zova_user_database_path(out: [*c]u8, out_size: usize) c_int { + const cache = c.cbm_resolve_cache_dir(); + if (out == null or out_size == 0 or cache == null or cache[0] == 0) return -1; + const n = interop.bprint(out[0..out_size], "{s}/cbm.zova", .{std.mem.span(cache)}); + return if (n > 0 and n < out_size) 0 else -1; +} diff --git a/src/zova/cbm_zova_v5_snapshot.c b/src/zova/cbm_zova_v5_snapshot.c deleted file mode 100644 index e16e2008..00000000 --- a/src/zova/cbm_zova_v5_snapshot.c +++ /dev/null @@ -1,663 +0,0 @@ -#include "zova/cbm_zova_v5_snapshot.h" -#include "foundation/platform.h" - -#include - -#include -#include -#include -#include - -typedef struct { - char *workspace_id; - int64_t active_generation; - cbm_zova_workspace_generation_input_t input; - char *root_path; - char *project; - char *indexed_at; - char *model_fingerprint; - CBMDumpNode *nodes; - char **stable_node_ids; - CBMDumpEdge *edges; - CBMDumpVector *node_vectors; - CBMDumpTokenVec *token_vectors; - cbm_zova_file_hash_input_t *file_hashes; - char *summary; - char *summary_source_hash; - char *summary_created_at; - char *summary_updated_at; -} v5_workspace_t; - -struct cbm_zova_v5_snapshot { - sqlite3 *db; - v5_workspace_t *workspaces; - int workspace_count; -}; - -static char *v5_text(sqlite3_stmt *stmt, int column) { - if (sqlite3_column_type(stmt, column) == SQLITE_NULL) return NULL; - const unsigned char *value = sqlite3_column_text(stmt, column); - return value ? strdup((const char *)value) : NULL; -} - -static uint8_t *v5_blob(sqlite3_stmt *stmt, int column, int expected_length) { - int length = sqlite3_column_bytes(stmt, column); - const void *value = sqlite3_column_blob(stmt, column); - if (!value || length != expected_length || length <= 0) return NULL; - uint8_t *copy = malloc((size_t)length); - if (copy) memcpy(copy, value, (size_t)length); - return copy; -} - -static int v5_scalar(sqlite3 *db, const char *sql, const char *text, - int64_t *out_value) { - sqlite3_stmt *stmt = NULL; - if (!db || !sql || !out_value || sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) - return -1; - int rc = 0; - if (text && sqlite3_bind_text(stmt, 1, text, -1, SQLITE_STATIC) != SQLITE_OK) rc = -1; - if (rc == 0 && sqlite3_step(stmt) != SQLITE_ROW) rc = -1; - if (rc == 0) *out_value = sqlite3_column_int64(stmt, 0); - if (rc == 0 && sqlite3_step(stmt) != SQLITE_DONE) rc = -1; - sqlite3_finalize(stmt); - return rc; -} - -static int v5_count(sqlite3 *db, const char *table, const char *workspace_id, - int *out_count) { - char sql[256]; - if (!table || !workspace_id || !out_count || - snprintf(sql, sizeof(sql), "SELECT count(*) FROM %s WHERE workspace_id=?1", table) >= - (int)sizeof(sql)) - return -1; - int64_t count = 0; - if (v5_scalar(db, sql, workspace_id, &count) != 0 || count < 0 || count > INT_MAX) return -1; - *out_count = (int)count; - return 0; -} - -static int v5_bind_workspace(sqlite3_stmt *stmt, const char *workspace_id) { - return sqlite3_bind_text(stmt, 1, workspace_id, -1, SQLITE_STATIC) == SQLITE_OK ? 0 : -1; -} - -static int v5_node_index(const v5_workspace_t *workspace, const char *stable_id) { - if (!workspace || !stable_id) return -1; - for (int i = 0; i < workspace->input.node_count; i++) { - if (strcmp(workspace->stable_node_ids[i], stable_id) == 0) return i; - } - return -1; -} - -static int v5_node_stable_id_matches(const char *workspace_id, const CBMDumpNode *node, - const char *stable_id) { - if (!workspace_id || !node || !node->file_path || !stable_id) return -1; - char relative_path[4096]; - char discriminator[512]; - char expected[CBM_ZOVA_WORKSPACE_ID_MAX + 64]; - if (snprintf(relative_path, sizeof(relative_path), "%s", node->file_path) >= - (int)sizeof(relative_path)) - return -1; - cbm_normalize_path_sep(relative_path); - if (node->qualified_name && node->qualified_name[0]) { - if (snprintf(discriminator, sizeof(discriminator), "named:%s", node->qualified_name) >= - (int)sizeof(discriminator)) - return -1; - } else if (!node->name || !node->name[0] || node->start_line < 0 || - node->end_line < node->start_line || - snprintf(discriminator, sizeof(discriminator), "local:%s:span:%d:%d", node->name, - node->start_line, node->end_line) >= (int)sizeof(discriminator)) { - return -1; - } - return cbm_zova_workspace_node_id_v2(workspace_id, node->label, relative_path, - node->qualified_name, discriminator, expected, - sizeof(expected)) == 0 && - strcmp(expected, stable_id) == 0 - ? 0 - : -1; -} - -static int v5_camel_split(const char *input, char **out) { - if (out) *out = NULL; - if (!input || !out) return -1; - size_t input_len = strlen(input); - if (input_len == 0) { - *out = strdup(""); - return *out ? 0 : -1; - } - if (input_len > (SIZE_MAX - 2) / 2) return -1; - char *result = malloc(input_len * 2 + 2); - if (!result) return -1; - memcpy(result, input, input_len); - size_t len = input_len; - result[len++] = ' '; - for (size_t i = 0; i < input_len; i++) { - char curr = input[i]; - char prev = i > 0 ? input[i - 1] : '\0'; - char next = i + 1 < input_len ? input[i + 1] : '\0'; - if (i > 0 && curr >= 'A' && curr <= 'Z' && - ((prev >= 'a' && prev <= 'z') || - (prev >= 'A' && prev <= 'Z' && next >= 'a' && next <= 'z'))) - result[len++] = ' '; - result[len++] = curr; - } - result[len] = '\0'; - *out = result; - return 0; -} - -static void v5_workspace_free(v5_workspace_t *workspace) { - if (!workspace) return; - for (int i = 0; i < workspace->input.node_count; i++) { - free((char *)workspace->nodes[i].label); - free((char *)workspace->nodes[i].name); - free((char *)workspace->nodes[i].qualified_name); - free((char *)workspace->nodes[i].file_path); - free((char *)workspace->nodes[i].properties); - free(workspace->stable_node_ids ? workspace->stable_node_ids[i] : NULL); - } - for (int i = 0; i < workspace->input.edge_count; i++) { - free((char *)workspace->edges[i].type); - free((char *)workspace->edges[i].properties); - free((char *)workspace->edges[i].url_path); - free((char *)workspace->edges[i].local_name); - } - for (int i = 0; i < workspace->input.node_vector_count; i++) - free((void *)workspace->node_vectors[i].vector); - for (int i = 0; i < workspace->input.token_vector_count; i++) { - free((char *)workspace->token_vectors[i].token); - free((void *)workspace->token_vectors[i].vector); - } - for (int i = 0; i < workspace->input.file_hash_count; i++) { - free((char *)workspace->file_hashes[i].file_path); - free((char *)workspace->file_hashes[i].content_hash); - } - free(workspace->nodes); - free(workspace->stable_node_ids); - free(workspace->edges); - free(workspace->node_vectors); - free(workspace->token_vectors); - free(workspace->file_hashes); - free(workspace->workspace_id); - free(workspace->root_path); - free(workspace->project); - free(workspace->indexed_at); - free(workspace->model_fingerprint); - free(workspace->summary); - free(workspace->summary_source_hash); - free(workspace->summary_created_at); - free(workspace->summary_updated_at); - memset(workspace, 0, sizeof(*workspace)); -} - -void cbm_zova_v5_snapshot_close(cbm_zova_v5_snapshot_t *snapshot) { - if (!snapshot) return; - for (int i = 0; i < snapshot->workspace_count; i++) - v5_workspace_free(&snapshot->workspaces[i]); - free(snapshot->workspaces); - if (snapshot->db) { - (void)sqlite3_exec(snapshot->db, "ROLLBACK", NULL, NULL, NULL); - sqlite3_close(snapshot->db); - } - free(snapshot); -} - -static int v5_validate_source(sqlite3 *db) { - static const char *required[] = { - "_zova_meta", "cbm_database_schema_v1", "cbm_workspace_registry", - "cbm_database_generation_v1", "cbm_projects_v1", "cbm_nodes_v1", - "cbm_edges_v1", "cbm_file_hashes_v1", "cbm_project_summaries_v2", - "cbm_generation_integrity_v2", "cbm_node_vectors_compat_v1", - "cbm_token_vectors_compat_v1", "cbm_nodes_fts_v1", "cbm_fts_rowmap_v1", - "cbm_workspace_index_state_v1", - }; - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(db, - "SELECT count(*) FROM sqlite_master WHERE type IN ('table','view') " - "AND name=?1", - -1, &stmt, NULL) != SQLITE_OK) - return -1; - int rc = 0; - for (size_t i = 0; rc == 0 && i < sizeof(required) / sizeof(required[0]); i++) { - if (sqlite3_bind_text(stmt, 1, required[i], -1, SQLITE_STATIC) != SQLITE_OK || - sqlite3_step(stmt) != SQLITE_ROW || sqlite3_column_int64(stmt, 0) != 1) { - rc = -1; - } - if (sqlite3_reset(stmt) != SQLITE_OK || sqlite3_clear_bindings(stmt) != SQLITE_OK) rc = -1; - } - sqlite3_finalize(stmt); - if (rc != 0) return -1; - int64_t schema_version = 0; - int64_t format_matches = 0; - if (v5_scalar(db, "SELECT schema_version FROM cbm_database_schema_v1 WHERE id=1", NULL, - &schema_version) != 0 || - schema_version != 5 || - v5_scalar(db, - "SELECT count(*) FROM _zova_meta WHERE key='format_version' AND value='7'", - NULL, &format_matches) != 0 || - format_matches != 1) - return -1; - return 0; -} - -static int v5_read_nodes(sqlite3 *db, v5_workspace_t *workspace) { - int count = 0; - if (v5_count(db, "cbm_nodes_v1", workspace->workspace_id, &count) != 0) return -1; - workspace->input.node_count = count; - if (count == 0) return 0; - workspace->nodes = calloc((size_t)count, sizeof(*workspace->nodes)); - workspace->stable_node_ids = calloc((size_t)count, sizeof(*workspace->stable_node_ids)); - if (!workspace->nodes || !workspace->stable_node_ids) return -1; - sqlite3_stmt *stmt = NULL; - const char *sql = - "SELECT n.node_id,n.label,n.name,n.qualified_name,n.file_path,n.start_line,n.end_line," - "n.properties,m.fts_rowid FROM cbm_nodes_v1 n JOIN cbm_fts_rowmap_v1 m ON " - "m.workspace_id=n.workspace_id AND m.node_id=n.node_id WHERE n.workspace_id=?1 " - "ORDER BY m.fts_rowid"; - if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK || - v5_bind_workspace(stmt, workspace->workspace_id) != 0) - return sqlite3_finalize(stmt), -1; - int row = 0; - while (row < count && sqlite3_step(stmt) == SQLITE_ROW) { - CBMDumpNode *node = &workspace->nodes[row]; - workspace->stable_node_ids[row] = v5_text(stmt, 0); - node->id = sqlite3_column_int64(stmt, 8); - node->project = workspace->project; - node->label = v5_text(stmt, 1); - node->name = v5_text(stmt, 2); - node->qualified_name = v5_text(stmt, 3); - node->file_path = v5_text(stmt, 4); - node->start_line = sqlite3_column_int(stmt, 5); - node->end_line = sqlite3_column_int(stmt, 6); - node->properties = v5_text(stmt, 7); - if (!workspace->stable_node_ids[row] || !node->label || !node->name || - !node->qualified_name || !node->file_path || !node->properties || node->id <= 0 || - (row > 0 && workspace->nodes[row - 1].id >= node->id) || - v5_node_stable_id_matches(workspace->workspace_id, node, - workspace->stable_node_ids[row]) != 0) { - sqlite3_finalize(stmt); - return -1; - } - row++; - } - int rc = row == count && sqlite3_step(stmt) == SQLITE_DONE ? 0 : -1; - sqlite3_finalize(stmt); - workspace->input.nodes = workspace->nodes; - return rc; -} - -static int v5_read_edges(sqlite3 *db, v5_workspace_t *workspace) { - int count = 0; - if (v5_count(db, "cbm_edges_v1", workspace->workspace_id, &count) != 0) return -1; - workspace->input.edge_count = count; - if (count == 0) return 0; - workspace->edges = calloc((size_t)count, sizeof(*workspace->edges)); - if (!workspace->edges) return -1; - sqlite3_stmt *stmt = NULL; - const char *sql = - "SELECT source_node_id,target_node_id,edge_type,properties,url_path,local_name " - "FROM cbm_edges_v1 WHERE workspace_id=?1 ORDER BY edge_id"; - if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK || - v5_bind_workspace(stmt, workspace->workspace_id) != 0) - return sqlite3_finalize(stmt), -1; - int row = 0; - while (row < count && sqlite3_step(stmt) == SQLITE_ROW) { - char *source = v5_text(stmt, 0); - char *target = v5_text(stmt, 1); - int source_index = v5_node_index(workspace, source); - int target_index = v5_node_index(workspace, target); - CBMDumpEdge *edge = &workspace->edges[row]; - edge->id = row + 1; - edge->project = workspace->project; - edge->source_id = source_index < 0 ? 0 : workspace->nodes[source_index].id; - edge->target_id = target_index < 0 ? 0 : workspace->nodes[target_index].id; - edge->type = v5_text(stmt, 2); - edge->properties = v5_text(stmt, 3); - edge->url_path = v5_text(stmt, 4); - edge->local_name = v5_text(stmt, 5); - free(source); - free(target); - if (source_index < 0 || target_index < 0 || !edge->type || !edge->type[0] || - !edge->properties || !edge->url_path || !edge->local_name) { - sqlite3_finalize(stmt); - return -1; - } - row++; - } - int rc = row == count && sqlite3_step(stmt) == SQLITE_DONE ? 0 : -1; - sqlite3_finalize(stmt); - workspace->input.edges = workspace->edges; - return rc; -} - -static int v5_read_node_vectors(sqlite3 *db, v5_workspace_t *workspace) { - int count = 0; - if (v5_count(db, "cbm_node_vectors_compat_v1", workspace->workspace_id, &count) != 0) - return -1; - workspace->input.node_vector_count = count; - if (count == 0) return 0; - workspace->node_vectors = calloc((size_t)count, sizeof(*workspace->node_vectors)); - if (!workspace->node_vectors) return -1; - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(db, - "SELECT node_id,vector FROM cbm_node_vectors_compat_v1 " - "WHERE workspace_id=?1 ORDER BY node_id", - -1, &stmt, NULL) != SQLITE_OK || - v5_bind_workspace(stmt, workspace->workspace_id) != 0) - return sqlite3_finalize(stmt), -1; - int row = 0; - while (row < count && sqlite3_step(stmt) == SQLITE_ROW) { - char *node_id = v5_text(stmt, 0); - int node_index = v5_node_index(workspace, node_id); - CBMDumpVector *vector = &workspace->node_vectors[row]; - vector->node_id = node_index < 0 ? 0 : workspace->nodes[node_index].id; - vector->project = workspace->project; - vector->vector_len = workspace->input.vector_dimensions; - vector->vector = v5_blob(stmt, 1, vector->vector_len); - free(node_id); - if (node_index < 0 || !vector->vector) { - sqlite3_finalize(stmt); - return -1; - } - row++; - } - int rc = row == count && sqlite3_step(stmt) == SQLITE_DONE ? 0 : -1; - sqlite3_finalize(stmt); - workspace->input.node_vectors = workspace->node_vectors; - return rc; -} - -static int v5_read_token_vectors(sqlite3 *db, v5_workspace_t *workspace) { - int count = 0; - if (v5_count(db, "cbm_token_vectors_compat_v1", workspace->workspace_id, &count) != 0) - return -1; - workspace->input.token_vector_count = count; - if (count == 0) return 0; - workspace->token_vectors = calloc((size_t)count, sizeof(*workspace->token_vectors)); - if (!workspace->token_vectors) return -1; - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(db, - "SELECT token_id,token,vector,idf FROM cbm_token_vectors_compat_v1 " - "WHERE workspace_id=?1 ORDER BY token_id", - -1, &stmt, NULL) != SQLITE_OK || - v5_bind_workspace(stmt, workspace->workspace_id) != 0) - return sqlite3_finalize(stmt), -1; - int row = 0; - int64_t previous_id = 0; - while (row < count && sqlite3_step(stmt) == SQLITE_ROW) { - CBMDumpTokenVec *vector = &workspace->token_vectors[row]; - vector->id = sqlite3_column_int64(stmt, 0); - vector->project = workspace->project; - vector->token = v5_text(stmt, 1); - vector->vector_len = workspace->input.vector_dimensions; - vector->vector = v5_blob(stmt, 2, vector->vector_len); - vector->idf = (float)sqlite3_column_double(stmt, 3); - if (vector->id <= previous_id || !vector->token || !vector->vector) { - sqlite3_finalize(stmt); - return -1; - } - previous_id = vector->id; - row++; - } - int rc = row == count && sqlite3_step(stmt) == SQLITE_DONE ? 0 : -1; - sqlite3_finalize(stmt); - workspace->input.token_vectors = workspace->token_vectors; - return rc; -} - -static int v5_read_hashes(sqlite3 *db, v5_workspace_t *workspace) { - int count = 0; - if (v5_count(db, "cbm_file_hashes_v1", workspace->workspace_id, &count) != 0) return -1; - workspace->input.file_hash_count = count; - if (count == 0) return 0; - workspace->file_hashes = calloc((size_t)count, sizeof(*workspace->file_hashes)); - if (!workspace->file_hashes) return -1; - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(db, - "SELECT file_path,content_hash,mtime_ns,size_bytes FROM " - "cbm_file_hashes_v1 WHERE workspace_id=?1 ORDER BY file_path", - -1, &stmt, NULL) != SQLITE_OK || - v5_bind_workspace(stmt, workspace->workspace_id) != 0) - return sqlite3_finalize(stmt), -1; - int row = 0; - while (row < count && sqlite3_step(stmt) == SQLITE_ROW) { - cbm_zova_file_hash_input_t *hash = &workspace->file_hashes[row]; - hash->file_path = v5_text(stmt, 0); - hash->content_hash = v5_text(stmt, 1); - hash->mtime_ns = sqlite3_column_int64(stmt, 2); - hash->size_bytes = sqlite3_column_int64(stmt, 3); - if (!hash->file_path || !hash->content_hash || - (row > 0 && strcmp(workspace->file_hashes[row - 1].file_path, hash->file_path) >= 0)) { - sqlite3_finalize(stmt); - return -1; - } - row++; - } - int rc = row == count && sqlite3_step(stmt) == SQLITE_DONE ? 0 : -1; - sqlite3_finalize(stmt); - workspace->input.file_hashes = workspace->file_hashes; - return rc; -} - -static int v5_read_summary(sqlite3 *db, v5_workspace_t *workspace) { - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(db, - "SELECT summary,source_hash,created_at,updated_at FROM " - "cbm_project_summaries_v2 WHERE workspace_id=?1", - -1, &stmt, NULL) != SQLITE_OK || - v5_bind_workspace(stmt, workspace->workspace_id) != 0) - return sqlite3_finalize(stmt), -1; - int step = sqlite3_step(stmt); - if (step == SQLITE_DONE) { - sqlite3_finalize(stmt); - return 0; - } - if (step != SQLITE_ROW) { - sqlite3_finalize(stmt); - return -1; - } - workspace->summary = v5_text(stmt, 0); - workspace->summary_source_hash = v5_text(stmt, 1); - workspace->summary_created_at = v5_text(stmt, 2); - workspace->summary_updated_at = v5_text(stmt, 3); - if (!workspace->summary || !workspace->summary_source_hash || - !workspace->summary_created_at || !workspace->summary_updated_at || - sqlite3_step(stmt) != SQLITE_DONE) { - sqlite3_finalize(stmt); - return -1; - } - sqlite3_finalize(stmt); - workspace->input.project_summary = (cbm_zova_project_summary_input_t){ - .present = true, - .summary = workspace->summary, - .source_hash = workspace->summary_source_hash, - .created_at = workspace->summary_created_at, - .updated_at = workspace->summary_updated_at, - }; - return 0; -} - -static int v5_validate_fts(sqlite3 *db, const v5_workspace_t *workspace) { - int fts_count = 0; - int rowmap_count = 0; - if (v5_count(db, "cbm_nodes_fts_v1", workspace->workspace_id, &fts_count) != 0 || - v5_count(db, "cbm_fts_rowmap_v1", workspace->workspace_id, &rowmap_count) != 0 || - fts_count != workspace->input.node_count || rowmap_count != workspace->input.node_count) - return -1; - int64_t joined = 0; - const char *sql = - "SELECT count(*) FROM cbm_fts_rowmap_v1 m " - "JOIN cbm_nodes_fts_v1 f ON f.workspace_id=m.workspace_id AND f.node_id=m.node_id " - "JOIN cbm_nodes_v1 n ON n.workspace_id=m.workspace_id " - "AND n.node_id=m.node_id WHERE m.workspace_id=?1 AND " - "f.qualified_name=n.qualified_name AND f.file_path=n.file_path AND f.label=n.label"; - if (v5_scalar(db, sql, workspace->workspace_id, &joined) != 0 || - joined != workspace->input.node_count) - return -1; - sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(db, - "SELECT f.name FROM cbm_fts_rowmap_v1 m JOIN cbm_nodes_fts_v1 f " - "ON f.workspace_id=m.workspace_id AND f.node_id=m.node_id WHERE " - "m.workspace_id=?1 ORDER BY m.fts_rowid", - -1, &stmt, NULL) != SQLITE_OK || - v5_bind_workspace(stmt, workspace->workspace_id) != 0) - return sqlite3_finalize(stmt), -1; - int row = 0; - int step = SQLITE_ROW; - while (row < workspace->input.node_count && (step = sqlite3_step(stmt)) == SQLITE_ROW) { - char *expected = NULL; - const unsigned char *actual = sqlite3_column_text(stmt, 0); - if (!actual || v5_camel_split(workspace->nodes[row].name, &expected) != 0 || - strcmp((const char *)actual, expected) != 0) { - free(expected); - sqlite3_finalize(stmt); - return -1; - } - free(expected); - row++; - } - if (row == workspace->input.node_count && step == SQLITE_ROW) step = sqlite3_step(stmt); - int rc = row == workspace->input.node_count && step == SQLITE_DONE ? 0 : -1; - sqlite3_finalize(stmt); - return rc; -} - -static int v5_validate_integrity(sqlite3 *db, const v5_workspace_t *workspace) { - cbm_zova_workspace_generation_result_t actual = {0}; - if (cbm_zova_workspace_generation_digest_input(workspace->workspace_id, &workspace->input, - &actual) != 0) - return -1; - sqlite3_stmt *stmt = NULL; - const char *sql = - "SELECT graph_nodes,graph_edges,metadata_nodes,metadata_edges,metadata_topology_edges," - "fts_rows,node_vector_rows,token_vector_rows,node_vectors,token_vectors,metadata_sha256," - "fts_sha256,topology_sha256,node_vector_sha256,token_vector_sha256 FROM " - "cbm_generation_integrity_v2 WHERE workspace_id=?1 AND generation=?2"; - if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK || - sqlite3_bind_text(stmt, 1, workspace->workspace_id, -1, SQLITE_STATIC) != SQLITE_OK || - sqlite3_bind_int64(stmt, 2, workspace->active_generation) != SQLITE_OK || - sqlite3_step(stmt) != SQLITE_ROW) { - sqlite3_finalize(stmt); - return -1; - } - const uint64_t expected_counts[] = { - actual.graph_nodes, actual.graph_edges, actual.metadata_nodes, actual.metadata_edges, - actual.metadata_topology_edges, actual.fts_rows, actual.node_vector_rows, - actual.token_vector_rows, actual.node_vectors, actual.token_vectors, - }; - int rc = 0; - for (int i = 0; rc == 0 && i < 10; i++) - if (sqlite3_column_int64(stmt, i) < 0 || - (uint64_t)sqlite3_column_int64(stmt, i) != expected_counts[i]) - rc = -1; - const char *expected_digests[] = {actual.metadata_sha256, actual.fts_sha256, - actual.topology_sha256, actual.node_vector_sha256, - actual.token_vector_sha256}; - for (int i = 0; rc == 0 && i < 5; i++) { - const unsigned char *digest = sqlite3_column_text(stmt, 10 + i); - if (!digest || strcmp((const char *)digest, expected_digests[i]) != 0) rc = -1; - } - if (rc == 0 && sqlite3_step(stmt) != SQLITE_DONE) rc = -1; - sqlite3_finalize(stmt); - return rc; -} - -static int v5_read_workspace(sqlite3 *db, sqlite3_stmt *workspace_stmt, - v5_workspace_t *workspace) { - workspace->workspace_id = v5_text(workspace_stmt, 0); - workspace->active_generation = sqlite3_column_int64(workspace_stmt, 1); - workspace->project = v5_text(workspace_stmt, 2); - workspace->root_path = v5_text(workspace_stmt, 3); - workspace->indexed_at = v5_text(workspace_stmt, 4); - workspace->model_fingerprint = v5_text(workspace_stmt, 5); - workspace->input.vector_dimensions = sqlite3_column_int(workspace_stmt, 6); - if (!workspace->workspace_id || !workspace->project || !workspace->root_path || - !workspace->indexed_at || !workspace->model_fingerprint || - workspace->active_generation <= 0 || workspace->input.vector_dimensions <= 0) - return -1; - workspace->input.root_path = workspace->root_path; - workspace->input.project = workspace->project; - workspace->input.indexed_at = workspace->indexed_at; - workspace->input.model_fingerprint = workspace->model_fingerprint; - return v5_read_nodes(db, workspace) == 0 && v5_read_edges(db, workspace) == 0 && - v5_read_node_vectors(db, workspace) == 0 && - v5_read_token_vectors(db, workspace) == 0 && - v5_read_hashes(db, workspace) == 0 && v5_read_summary(db, workspace) == 0 && - v5_validate_fts(db, workspace) == 0 && - v5_validate_integrity(db, workspace) == 0 - ? 0 - : -1; -} - -int cbm_zova_v5_snapshot_open(const char *source_path, - cbm_zova_v5_snapshot_t **out_snapshot) { - if (out_snapshot) *out_snapshot = NULL; - if (!source_path || !source_path[0] || !out_snapshot) return -1; - cbm_zova_v5_snapshot_t *snapshot = calloc(1, sizeof(*snapshot)); - if (!snapshot) return -1; - int flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX; - if (sqlite3_open_v2(source_path, &snapshot->db, flags, NULL) != SQLITE_OK || - sqlite3_exec(snapshot->db, "PRAGMA query_only=ON;BEGIN", NULL, NULL, NULL) != SQLITE_OK || - v5_validate_source(snapshot->db) != 0) { - cbm_zova_v5_snapshot_close(snapshot); - return -1; - } - int64_t count = 0; - const char *count_sql = - "SELECT count(*) FROM cbm_workspace_registry r JOIN cbm_projects_v1 p USING(workspace_id) " - "JOIN cbm_workspace_index_state_v1 s USING(workspace_id) JOIN cbm_database_generation_v1 g " - "ON g.workspace_id=r.workspace_id AND g.generation=r.active_generation " - "WHERE r.active_generation>0 AND s.generation=r.active_generation AND g.state='ready'"; - if (v5_scalar(snapshot->db, count_sql, NULL, &count) != 0 || count <= 0 || count > INT_MAX) { - cbm_zova_v5_snapshot_close(snapshot); - return -1; - } - snapshot->workspace_count = (int)count; - snapshot->workspaces = calloc((size_t)count, sizeof(*snapshot->workspaces)); - if (!snapshot->workspaces) { - cbm_zova_v5_snapshot_close(snapshot); - return -1; - } - sqlite3_stmt *stmt = NULL; - const char *sql = - "SELECT r.workspace_id,r.active_generation,p.project,p.root_path,p.indexed_at," - "s.model_fingerprint,s.vector_dimensions FROM cbm_workspace_registry r " - "JOIN cbm_projects_v1 p USING(workspace_id) JOIN cbm_workspace_index_state_v1 s " - "USING(workspace_id) JOIN cbm_database_generation_v1 g ON " - "g.workspace_id=r.workspace_id AND g.generation=r.active_generation WHERE " - "r.active_generation>0 AND s.generation=r.active_generation AND g.state='ready' " - "ORDER BY r.workspace_id"; - if (sqlite3_prepare_v2(snapshot->db, sql, -1, &stmt, NULL) != SQLITE_OK) { - cbm_zova_v5_snapshot_close(snapshot); - return -1; - } - int row = 0; - int rc = 0; - while (row < snapshot->workspace_count && sqlite3_step(stmt) == SQLITE_ROW) { - if (v5_read_workspace(snapshot->db, stmt, &snapshot->workspaces[row]) != 0) { - rc = -1; - break; - } - row++; - } - if (rc == 0 && (row != snapshot->workspace_count || sqlite3_step(stmt) != SQLITE_DONE)) rc = -1; - sqlite3_finalize(stmt); - if (rc != 0) { - cbm_zova_v5_snapshot_close(snapshot); - return -1; - } - *out_snapshot = snapshot; - return 0; -} - -int cbm_zova_v5_snapshot_workspace_count(const cbm_zova_v5_snapshot_t *snapshot) { - return snapshot ? snapshot->workspace_count : -1; -} - -const cbm_zova_workspace_generation_input_t *cbm_zova_v5_snapshot_input_at( - const cbm_zova_v5_snapshot_t *snapshot, int index, int64_t *out_active_generation) { - if (out_active_generation) *out_active_generation = 0; - if (!snapshot || index < 0 || index >= snapshot->workspace_count) return NULL; - if (out_active_generation) - *out_active_generation = snapshot->workspaces[index].active_generation; - return &snapshot->workspaces[index].input; -} diff --git a/src/zova/cbm_zova_v5_snapshot.zig b/src/zova/cbm_zova_v5_snapshot.zig new file mode 100644 index 00000000..ea2d9d5a --- /dev/null +++ b/src/zova/cbm_zova_v5_snapshot.zig @@ -0,0 +1,586 @@ +//! cbm_zova_v5_snapshot.zig — Read-only, fully validated view of a schema-5 +//! cbm.zova file, as the input of an atomic repack. +//! +//! Implements src/zova/cbm_zova_v5_snapshot.h. Opening reads every ready +//! workspace into memory and checks it against the stored FTS rows and +//! generation digests; any mismatch fails the open. + +const std = @import("std"); +const c = @import("c"); +const interop = @import("../foundation/interop.zig"); + +const Str = interop.Str; +const CStr = interop.CStr; +const Stmt = c.sqlite3_stmt; +const Input = c.cbm_zova_workspace_generation_input_t; + +const V5Workspace = struct { + workspace_id: [*c]u8 = null, + active_generation: i64 = 0, + input: Input = std.mem.zeroes(Input), + root_path: [*c]u8 = null, + project: [*c]u8 = null, + indexed_at: [*c]u8 = null, + model_fingerprint: [*c]u8 = null, + nodes: []c.CBMDumpNode = &.{}, + stable_node_ids: [][*c]u8 = &.{}, + edges: []c.CBMDumpEdge = &.{}, + node_vectors: []c.CBMDumpVector = &.{}, + token_vectors: []c.CBMDumpTokenVec = &.{}, + file_hashes: []c.cbm_zova_file_hash_input_t = &.{}, + summary: [*c]u8 = null, + summary_source_hash: [*c]u8 = null, + summary_created_at: [*c]u8 = null, + summary_updated_at: [*c]u8 = null, +}; + +const Snapshot = struct { + db: ?*c.sqlite3 = null, + workspaces: []V5Workspace = &.{}, +}; + +/// A zeroed malloc'd array; NULL on OOM. +fn callocSlice(comptime T: type, n: usize) ?[]T { + const items: ?[*]T = interop.callocN(T, n); + return (items orelse return null)[0..n]; +} + +/// free() for the borrowed-looking `const` pointers of the dump structs, +/// which this module owns. +fn freeConst(p: anytype) void { + c.free(@ptrCast(@constCast(p))); +} + +/// Frees a callocSlice array. An empty slice owns no allocation. +fn freeSlice(items: anytype) void { + if (items.len != 0) c.free(@ptrCast(items.ptr)); +} + +fn finalize(stmt: ?*Stmt) void { + _ = c.sqlite3_finalize(stmt); +} + +fn v5Text(stmt: ?*Stmt, column: c_int) [*c]u8 { + if (c.sqlite3_column_type(stmt, column) == c.SQLITE_NULL) return null; + return interop.heapStrdup(c.sqlite3_column_text(stmt, column)); +} + +fn v5Blob(stmt: ?*Stmt, column: c_int, expected_length: c_int) [*c]u8 { + const length = c.sqlite3_column_bytes(stmt, column); + const value: ?[*]const u8 = @ptrCast(c.sqlite3_column_blob(stmt, column)); + const bytes = value orelse return null; + if (length != expected_length or length <= 0) return null; + const n: usize = @intCast(length); + const copy = interop.mallocN(u8, n); + if (copy != null) @memcpy(copy[0..n], bytes[0..n]); + return copy; +} + +fn v5Scalar(db: ?*c.sqlite3, sql: CStr, text: CStr) ?i64 { + var stmt: ?*Stmt = null; + if (db == null or c.sqlite3_prepare_v2(db, sql, -1, &stmt, null) != c.SQLITE_OK) return null; + defer finalize(stmt); + if (text != null and c.sqlite3_bind_text(stmt, 1, text, -1, null) != c.SQLITE_OK) return null; + if (c.sqlite3_step(stmt) != c.SQLITE_ROW) return null; + const value = c.sqlite3_column_int64(stmt, 0); + if (c.sqlite3_step(stmt) != c.SQLITE_DONE) return null; + return value; +} + +fn v5Count(db: ?*c.sqlite3, comptime table: []const u8, workspace_id: CStr) ?c_int { + if (workspace_id == null) return null; + const count = v5Scalar(db, "SELECT count(*) FROM " ++ table ++ " WHERE workspace_id=?1", workspace_id) orelse return null; + if (count < 0 or count > std.math.maxInt(c_int)) return null; + return @intCast(count); +} + +/// Prepares `sql` with the workspace id bound to ?1. The caller finalizes. +fn v5PrepareWorkspace(db: ?*c.sqlite3, sql: CStr, workspace_id: CStr) ?*Stmt { + var stmt: ?*Stmt = null; + if (c.sqlite3_prepare_v2(db, sql, -1, &stmt, null) != c.SQLITE_OK or + c.sqlite3_bind_text(stmt, 1, workspace_id, -1, null) != c.SQLITE_OK) + { + finalize(stmt); + return null; + } + return stmt; +} + +fn v5NodeIndex(workspace: *const V5Workspace, stable_id: CStr) ?usize { + if (stable_id == null) return null; + for (workspace.stable_node_ids, 0..) |id, i| { + if (c.strcmp(id, stable_id) == 0) return i; + } + return null; +} + +fn v5NodeStableIdMatches(workspace_id: CStr, node: *const c.CBMDumpNode, stable_id: CStr) bool { + if (workspace_id == null or node.file_path == null or stable_id == null) return false; + var relative_path: [4096]u8 = undefined; + var discriminator: [512]u8 = undefined; + var expected: [c.CBM_ZOVA_WORKSPACE_ID_MAX + 64]u8 = undefined; + if (interop.bprint(&relative_path, "{s}", .{std.mem.span(node.file_path)}) >= relative_path.len) + return false; + _ = c.cbm_normalize_path_sep(&relative_path); + if (node.qualified_name != null and node.qualified_name[0] != 0) { + if (interop.bprint(&discriminator, "named:{s}", .{std.mem.span(node.qualified_name)}) >= discriminator.len) + return false; + } else if (node.name == null or node.name[0] == 0 or node.start_line < 0 or + node.end_line < node.start_line or + interop.bprint(&discriminator, "local:{s}:span:{d}:{d}", .{ std.mem.span(node.name), node.start_line, node.end_line }) >= discriminator.len) + { + return false; + } + return c.cbm_zova_workspace_node_id_v2(workspace_id, node.label, &relative_path, node.qualified_name, &discriminator, &expected, expected.len) == 0 and + c.strcmp(&expected, stable_id) == 0; +} + +/// The FTS name column of a node: the name, a space, then the name again with +/// a space before each camelCase word boundary. Malloc'd; NULL on failure. +fn v5CamelSplit(input: CStr) [*c]u8 { + if (input == null) return null; + const text = std.mem.span(input); + if (text.len == 0) return interop.heapStrdup(""); + if (text.len > (std.math.maxInt(usize) - 2) / 2) return null; + const result = interop.mallocN(u8, text.len * 2 + 2); + if (result == null) return null; + @memcpy(result[0..text.len], text); + var len = text.len; + result[len] = ' '; + len += 1; + for (text, 0..) |curr, i| { + const prev: u8 = if (i > 0) text[i - 1] else 0; + const next: u8 = if (i + 1 < text.len) text[i + 1] else 0; + if (i > 0 and std.ascii.isUpper(curr) and + (std.ascii.isLower(prev) or (std.ascii.isUpper(prev) and std.ascii.isLower(next)))) + { + result[len] = ' '; + len += 1; + } + result[len] = curr; + len += 1; + } + result[len] = 0; + return result; +} + +fn v5WorkspaceFree(workspace: *V5Workspace) void { + for (workspace.nodes) |node| { + freeConst(node.label); + freeConst(node.name); + freeConst(node.qualified_name); + freeConst(node.file_path); + freeConst(node.properties); + } + for (workspace.stable_node_ids) |id| c.free(id); + for (workspace.edges) |edge| { + freeConst(edge.type); + freeConst(edge.properties); + freeConst(edge.url_path); + freeConst(edge.local_name); + } + for (workspace.node_vectors) |vector| freeConst(vector.vector); + for (workspace.token_vectors) |vector| { + freeConst(vector.token); + freeConst(vector.vector); + } + for (workspace.file_hashes) |hash| { + freeConst(hash.file_path); + freeConst(hash.content_hash); + } + freeSlice(workspace.nodes); + freeSlice(workspace.stable_node_ids); + freeSlice(workspace.edges); + freeSlice(workspace.node_vectors); + freeSlice(workspace.token_vectors); + freeSlice(workspace.file_hashes); + c.free(workspace.workspace_id); + c.free(workspace.root_path); + c.free(workspace.project); + c.free(workspace.indexed_at); + c.free(workspace.model_fingerprint); + c.free(workspace.summary); + c.free(workspace.summary_source_hash); + c.free(workspace.summary_created_at); + c.free(workspace.summary_updated_at); + workspace.* = .{}; +} + +pub export fn cbm_zova_v5_snapshot_close(snapshot: ?*Snapshot) void { + const s = snapshot orelse return; + for (s.workspaces) |*workspace| v5WorkspaceFree(workspace); + freeSlice(s.workspaces); + if (s.db != null) { + _ = c.sqlite3_exec(s.db, "ROLLBACK", null, null, null); + _ = c.sqlite3_close(s.db); + } + c.free(s); +} + +fn v5ValidateSource(db: ?*c.sqlite3) c_int { + const required = [_]Str{ + "_zova_meta", "cbm_database_schema_v1", "cbm_workspace_registry", + "cbm_database_generation_v1", "cbm_projects_v1", "cbm_nodes_v1", + "cbm_edges_v1", "cbm_file_hashes_v1", "cbm_project_summaries_v2", + "cbm_generation_integrity_v2", "cbm_node_vectors_compat_v1", "cbm_token_vectors_compat_v1", + "cbm_nodes_fts_v1", "cbm_fts_rowmap_v1", "cbm_workspace_index_state_v1", + }; + var stmt: ?*Stmt = null; + if (c.sqlite3_prepare_v2(db, "SELECT count(*) FROM sqlite_master WHERE type IN ('table','view') " ++ + "AND name=?1", -1, &stmt, null) != c.SQLITE_OK) + return -1; + var rc: c_int = 0; + for (required) |name| { + if (c.sqlite3_bind_text(stmt, 1, name, -1, null) != c.SQLITE_OK or + c.sqlite3_step(stmt) != c.SQLITE_ROW or c.sqlite3_column_int64(stmt, 0) != 1) + { + rc = -1; + } + if (c.sqlite3_reset(stmt) != c.SQLITE_OK or c.sqlite3_clear_bindings(stmt) != c.SQLITE_OK) rc = -1; + if (rc != 0) break; + } + finalize(stmt); + if (rc != 0) return -1; + const schema_version = v5Scalar(db, "SELECT schema_version FROM cbm_database_schema_v1 WHERE id=1", null) orelse return -1; + if (schema_version != 5) return -1; + const format_matches = v5Scalar(db, "SELECT count(*) FROM _zova_meta WHERE key='format_version' AND value='7'", null) orelse return -1; + if (format_matches != 1) return -1; + return 0; +} + +fn v5ReadNodes(db: ?*c.sqlite3, workspace: *V5Workspace) c_int { + const count = v5Count(db, "cbm_nodes_v1", workspace.workspace_id) orelse return -1; + workspace.input.node_count = count; + if (count == 0) return 0; + workspace.nodes = callocSlice(c.CBMDumpNode, @intCast(count)) orelse return -1; + workspace.stable_node_ids = callocSlice([*c]u8, @intCast(count)) orelse return -1; + const sql = + "SELECT n.node_id,n.label,n.name,n.qualified_name,n.file_path,n.start_line,n.end_line," ++ + "n.properties,m.fts_rowid FROM cbm_nodes_v1 n JOIN cbm_fts_rowmap_v1 m ON " ++ + "m.workspace_id=n.workspace_id AND m.node_id=n.node_id WHERE n.workspace_id=?1 " ++ + "ORDER BY m.fts_rowid"; + const stmt = v5PrepareWorkspace(db, sql, workspace.workspace_id) orelse return -1; + defer finalize(stmt); + var row: usize = 0; + while (row < workspace.nodes.len and c.sqlite3_step(stmt) == c.SQLITE_ROW) : (row += 1) { + const node = &workspace.nodes[row]; + workspace.stable_node_ids[row] = v5Text(stmt, 0); + node.id = c.sqlite3_column_int64(stmt, 8); + node.project = workspace.project; + node.label = v5Text(stmt, 1); + node.name = v5Text(stmt, 2); + node.qualified_name = v5Text(stmt, 3); + node.file_path = v5Text(stmt, 4); + node.start_line = c.sqlite3_column_int(stmt, 5); + node.end_line = c.sqlite3_column_int(stmt, 6); + node.properties = v5Text(stmt, 7); + if (workspace.stable_node_ids[row] == null or node.label == null or node.name == null or + node.qualified_name == null or node.file_path == null or node.properties == null or + node.id <= 0 or (row > 0 and workspace.nodes[row - 1].id >= node.id) or + !v5NodeStableIdMatches(workspace.workspace_id, node, workspace.stable_node_ids[row])) + { + return -1; + } + } + if (row != workspace.nodes.len or c.sqlite3_step(stmt) != c.SQLITE_DONE) return -1; + workspace.input.nodes = workspace.nodes.ptr; + return 0; +} + +fn v5ReadEdges(db: ?*c.sqlite3, workspace: *V5Workspace) c_int { + const count = v5Count(db, "cbm_edges_v1", workspace.workspace_id) orelse return -1; + workspace.input.edge_count = count; + if (count == 0) return 0; + workspace.edges = callocSlice(c.CBMDumpEdge, @intCast(count)) orelse return -1; + const sql = + "SELECT source_node_id,target_node_id,edge_type,properties,url_path,local_name " ++ + "FROM cbm_edges_v1 WHERE workspace_id=?1 ORDER BY edge_id"; + const stmt = v5PrepareWorkspace(db, sql, workspace.workspace_id) orelse return -1; + defer finalize(stmt); + var row: usize = 0; + while (row < workspace.edges.len and c.sqlite3_step(stmt) == c.SQLITE_ROW) : (row += 1) { + const source = v5Text(stmt, 0); + const target = v5Text(stmt, 1); + const source_index = v5NodeIndex(workspace, source); + const target_index = v5NodeIndex(workspace, target); + c.free(source); + c.free(target); + const edge = &workspace.edges[row]; + edge.id = @intCast(row + 1); + edge.project = workspace.project; + edge.source_id = if (source_index) |i| workspace.nodes[i].id else 0; + edge.target_id = if (target_index) |i| workspace.nodes[i].id else 0; + edge.type = v5Text(stmt, 2); + edge.properties = v5Text(stmt, 3); + edge.url_path = v5Text(stmt, 4); + edge.local_name = v5Text(stmt, 5); + if (source_index == null or target_index == null or edge.type == null or edge.type[0] == 0 or + edge.properties == null or edge.url_path == null or edge.local_name == null) + { + return -1; + } + } + if (row != workspace.edges.len or c.sqlite3_step(stmt) != c.SQLITE_DONE) return -1; + workspace.input.edges = workspace.edges.ptr; + return 0; +} + +fn v5ReadNodeVectors(db: ?*c.sqlite3, workspace: *V5Workspace) c_int { + const count = v5Count(db, "cbm_node_vectors_compat_v1", workspace.workspace_id) orelse return -1; + workspace.input.node_vector_count = count; + if (count == 0) return 0; + workspace.node_vectors = callocSlice(c.CBMDumpVector, @intCast(count)) orelse return -1; + const stmt = v5PrepareWorkspace(db, "SELECT node_id,vector FROM cbm_node_vectors_compat_v1 " ++ + "WHERE workspace_id=?1 ORDER BY node_id", workspace.workspace_id) orelse return -1; + defer finalize(stmt); + var row: usize = 0; + while (row < workspace.node_vectors.len and c.sqlite3_step(stmt) == c.SQLITE_ROW) : (row += 1) { + const node_id = v5Text(stmt, 0); + const node_index = v5NodeIndex(workspace, node_id); + c.free(node_id); + const vector = &workspace.node_vectors[row]; + vector.node_id = if (node_index) |i| workspace.nodes[i].id else 0; + vector.project = workspace.project; + vector.vector_len = workspace.input.vector_dimensions; + vector.vector = v5Blob(stmt, 1, vector.vector_len); + if (node_index == null or vector.vector == null) return -1; + } + if (row != workspace.node_vectors.len or c.sqlite3_step(stmt) != c.SQLITE_DONE) return -1; + workspace.input.node_vectors = workspace.node_vectors.ptr; + return 0; +} + +fn v5ReadTokenVectors(db: ?*c.sqlite3, workspace: *V5Workspace) c_int { + const count = v5Count(db, "cbm_token_vectors_compat_v1", workspace.workspace_id) orelse return -1; + workspace.input.token_vector_count = count; + if (count == 0) return 0; + workspace.token_vectors = callocSlice(c.CBMDumpTokenVec, @intCast(count)) orelse return -1; + const stmt = v5PrepareWorkspace(db, "SELECT token_id,token,vector,idf FROM cbm_token_vectors_compat_v1 " ++ + "WHERE workspace_id=?1 ORDER BY token_id", workspace.workspace_id) orelse return -1; + defer finalize(stmt); + var row: usize = 0; + var previous_id: i64 = 0; + while (row < workspace.token_vectors.len and c.sqlite3_step(stmt) == c.SQLITE_ROW) : (row += 1) { + const vector = &workspace.token_vectors[row]; + vector.id = c.sqlite3_column_int64(stmt, 0); + vector.project = workspace.project; + vector.token = v5Text(stmt, 1); + vector.vector_len = workspace.input.vector_dimensions; + vector.vector = v5Blob(stmt, 2, vector.vector_len); + vector.idf = @floatCast(c.sqlite3_column_double(stmt, 3)); + if (vector.id <= previous_id or vector.token == null or vector.vector == null) return -1; + previous_id = vector.id; + } + if (row != workspace.token_vectors.len or c.sqlite3_step(stmt) != c.SQLITE_DONE) return -1; + workspace.input.token_vectors = workspace.token_vectors.ptr; + return 0; +} + +fn v5ReadHashes(db: ?*c.sqlite3, workspace: *V5Workspace) c_int { + const count = v5Count(db, "cbm_file_hashes_v1", workspace.workspace_id) orelse return -1; + workspace.input.file_hash_count = count; + if (count == 0) return 0; + workspace.file_hashes = callocSlice(c.cbm_zova_file_hash_input_t, @intCast(count)) orelse return -1; + const stmt = v5PrepareWorkspace(db, "SELECT file_path,content_hash,mtime_ns,size_bytes FROM " ++ + "cbm_file_hashes_v1 WHERE workspace_id=?1 ORDER BY file_path", workspace.workspace_id) orelse return -1; + defer finalize(stmt); + var row: usize = 0; + while (row < workspace.file_hashes.len and c.sqlite3_step(stmt) == c.SQLITE_ROW) : (row += 1) { + const hash = &workspace.file_hashes[row]; + hash.file_path = v5Text(stmt, 0); + hash.content_hash = v5Text(stmt, 1); + hash.mtime_ns = c.sqlite3_column_int64(stmt, 2); + hash.size_bytes = c.sqlite3_column_int64(stmt, 3); + if (hash.file_path == null or hash.content_hash == null or + (row > 0 and c.strcmp(workspace.file_hashes[row - 1].file_path, hash.file_path) >= 0)) + { + return -1; + } + } + if (row != workspace.file_hashes.len or c.sqlite3_step(stmt) != c.SQLITE_DONE) return -1; + workspace.input.file_hashes = workspace.file_hashes.ptr; + return 0; +} + +fn v5ReadSummary(db: ?*c.sqlite3, workspace: *V5Workspace) c_int { + const stmt = v5PrepareWorkspace(db, "SELECT summary,source_hash,created_at,updated_at FROM " ++ + "cbm_project_summaries_v2 WHERE workspace_id=?1", workspace.workspace_id) orelse return -1; + defer finalize(stmt); + const step = c.sqlite3_step(stmt); + if (step == c.SQLITE_DONE) return 0; + if (step != c.SQLITE_ROW) return -1; + workspace.summary = v5Text(stmt, 0); + workspace.summary_source_hash = v5Text(stmt, 1); + workspace.summary_created_at = v5Text(stmt, 2); + workspace.summary_updated_at = v5Text(stmt, 3); + if (workspace.summary == null or workspace.summary_source_hash == null or + workspace.summary_created_at == null or workspace.summary_updated_at == null or + c.sqlite3_step(stmt) != c.SQLITE_DONE) + { + return -1; + } + workspace.input.project_summary = .{ + .present = true, + .summary = workspace.summary, + .source_hash = workspace.summary_source_hash, + .created_at = workspace.summary_created_at, + .updated_at = workspace.summary_updated_at, + }; + return 0; +} + +fn v5ValidateFts(db: ?*c.sqlite3, workspace: *const V5Workspace) c_int { + const node_count = workspace.input.node_count; + const fts_count = v5Count(db, "cbm_nodes_fts_v1", workspace.workspace_id) orelse return -1; + const rowmap_count = v5Count(db, "cbm_fts_rowmap_v1", workspace.workspace_id) orelse return -1; + if (fts_count != node_count or rowmap_count != node_count) return -1; + const joined_sql = + "SELECT count(*) FROM cbm_fts_rowmap_v1 m " ++ + "JOIN cbm_nodes_fts_v1 f ON f.workspace_id=m.workspace_id AND f.node_id=m.node_id " ++ + "JOIN cbm_nodes_v1 n ON n.workspace_id=m.workspace_id " ++ + "AND n.node_id=m.node_id WHERE m.workspace_id=?1 AND " ++ + "f.qualified_name=n.qualified_name AND f.file_path=n.file_path AND f.label=n.label"; + const joined = v5Scalar(db, joined_sql, workspace.workspace_id) orelse return -1; + if (joined != node_count) return -1; + const stmt = v5PrepareWorkspace(db, "SELECT f.name FROM cbm_fts_rowmap_v1 m JOIN cbm_nodes_fts_v1 f " ++ + "ON f.workspace_id=m.workspace_id AND f.node_id=m.node_id WHERE " ++ + "m.workspace_id=?1 ORDER BY m.fts_rowid", workspace.workspace_id) orelse return -1; + defer finalize(stmt); + var step: c_int = c.SQLITE_ROW; + var row: usize = 0; + while (row < workspace.nodes.len) : (row += 1) { + step = c.sqlite3_step(stmt); + if (step != c.SQLITE_ROW) break; + const actual: CStr = c.sqlite3_column_text(stmt, 0); + if (actual == null) return -1; + const expected = v5CamelSplit(workspace.nodes[row].name); + defer c.free(expected); + if (expected == null or c.strcmp(actual, expected) != 0) return -1; + } + if (row == workspace.nodes.len and step == c.SQLITE_ROW) step = c.sqlite3_step(stmt); + return if (row == workspace.nodes.len and step == c.SQLITE_DONE) 0 else -1; +} + +fn v5ValidateIntegrity(db: ?*c.sqlite3, workspace: *const V5Workspace) c_int { + var actual = std.mem.zeroes(c.cbm_zova_workspace_generation_result_t); + if (c.cbm_zova_workspace_generation_digest_input(workspace.workspace_id, &workspace.input, &actual) != 0) + return -1; + const sql = + "SELECT graph_nodes,graph_edges,metadata_nodes,metadata_edges,metadata_topology_edges," ++ + "fts_rows,node_vector_rows,token_vector_rows,node_vectors,token_vectors,metadata_sha256," ++ + "fts_sha256,topology_sha256,node_vector_sha256,token_vector_sha256 FROM " ++ + "cbm_generation_integrity_v2 WHERE workspace_id=?1 AND generation=?2"; + const stmt = v5PrepareWorkspace(db, sql, workspace.workspace_id) orelse return -1; + defer finalize(stmt); + if (c.sqlite3_bind_int64(stmt, 2, workspace.active_generation) != c.SQLITE_OK or + c.sqlite3_step(stmt) != c.SQLITE_ROW) + return -1; + const expected_counts = [_]u64{ + actual.graph_nodes, actual.graph_edges, actual.metadata_nodes, actual.metadata_edges, + actual.metadata_topology_edges, actual.fts_rows, actual.node_vector_rows, actual.token_vector_rows, + actual.node_vectors, actual.token_vectors, + }; + for (expected_counts, 0..) |expected, i| { + const value = c.sqlite3_column_int64(stmt, @intCast(i)); + if (value < 0 or @as(u64, @intCast(value)) != expected) return -1; + } + const expected_digests = [_]CStr{ + &actual.metadata_sha256, &actual.fts_sha256, &actual.topology_sha256, + &actual.node_vector_sha256, &actual.token_vector_sha256, + }; + for (expected_digests, 0..) |expected, i| { + const digest: CStr = c.sqlite3_column_text(stmt, @intCast(10 + i)); + if (digest == null or c.strcmp(digest, expected) != 0) return -1; + } + return if (c.sqlite3_step(stmt) == c.SQLITE_DONE) 0 else -1; +} + +fn v5ReadWorkspace(db: ?*c.sqlite3, workspace_stmt: ?*Stmt, workspace: *V5Workspace) c_int { + workspace.workspace_id = v5Text(workspace_stmt, 0); + workspace.active_generation = c.sqlite3_column_int64(workspace_stmt, 1); + workspace.project = v5Text(workspace_stmt, 2); + workspace.root_path = v5Text(workspace_stmt, 3); + workspace.indexed_at = v5Text(workspace_stmt, 4); + workspace.model_fingerprint = v5Text(workspace_stmt, 5); + workspace.input.vector_dimensions = c.sqlite3_column_int(workspace_stmt, 6); + if (workspace.workspace_id == null or workspace.project == null or workspace.root_path == null or + workspace.indexed_at == null or workspace.model_fingerprint == null or + workspace.active_generation <= 0 or workspace.input.vector_dimensions <= 0) + return -1; + workspace.input.root_path = workspace.root_path; + workspace.input.project = workspace.project; + workspace.input.indexed_at = workspace.indexed_at; + workspace.input.model_fingerprint = workspace.model_fingerprint; + return if (v5ReadNodes(db, workspace) == 0 and v5ReadEdges(db, workspace) == 0 and + v5ReadNodeVectors(db, workspace) == 0 and v5ReadTokenVectors(db, workspace) == 0 and + v5ReadHashes(db, workspace) == 0 and v5ReadSummary(db, workspace) == 0 and + v5ValidateFts(db, workspace) == 0 and v5ValidateIntegrity(db, workspace) == 0) 0 else -1; +} + +fn v5Open(snapshot: *Snapshot, source_path: Str) c_int { + const flags = c.SQLITE_OPEN_READONLY | c.SQLITE_OPEN_NOMUTEX; + if (c.sqlite3_open_v2(source_path, &snapshot.db, flags, null) != c.SQLITE_OK or + c.sqlite3_exec(snapshot.db, "PRAGMA query_only=ON;BEGIN", null, null, null) != c.SQLITE_OK or + v5ValidateSource(snapshot.db) != 0) + return -1; + const count_sql = + "SELECT count(*) FROM cbm_workspace_registry r JOIN cbm_projects_v1 p USING(workspace_id) " ++ + "JOIN cbm_workspace_index_state_v1 s USING(workspace_id) JOIN cbm_database_generation_v1 g " ++ + "ON g.workspace_id=r.workspace_id AND g.generation=r.active_generation " ++ + "WHERE r.active_generation>0 AND s.generation=r.active_generation AND g.state='ready'"; + const count = v5Scalar(snapshot.db, count_sql, null) orelse return -1; + if (count <= 0 or count > std.math.maxInt(c_int)) return -1; + const workspaces: []V5Workspace = blk: { + const items: ?[*]V5Workspace = interop.mallocN(V5Workspace, @intCast(count)); + break :blk (items orelse return -1)[0..@intCast(count)]; + }; + for (workspaces) |*workspace| workspace.* = .{}; + snapshot.workspaces = workspaces; + const sql = + "SELECT r.workspace_id,r.active_generation,p.project,p.root_path,p.indexed_at," ++ + "s.model_fingerprint,s.vector_dimensions FROM cbm_workspace_registry r " ++ + "JOIN cbm_projects_v1 p USING(workspace_id) JOIN cbm_workspace_index_state_v1 s " ++ + "USING(workspace_id) JOIN cbm_database_generation_v1 g ON " ++ + "g.workspace_id=r.workspace_id AND g.generation=r.active_generation WHERE " ++ + "r.active_generation>0 AND s.generation=r.active_generation AND g.state='ready' " ++ + "ORDER BY r.workspace_id"; + var stmt: ?*Stmt = null; + if (c.sqlite3_prepare_v2(snapshot.db, sql, -1, &stmt, null) != c.SQLITE_OK) return -1; + defer finalize(stmt); + var row: usize = 0; + while (row < workspaces.len and c.sqlite3_step(stmt) == c.SQLITE_ROW) : (row += 1) { + if (v5ReadWorkspace(snapshot.db, stmt, &workspaces[row]) != 0) return -1; + } + if (row != workspaces.len or c.sqlite3_step(stmt) != c.SQLITE_DONE) return -1; + return 0; +} + +pub export fn cbm_zova_v5_snapshot_open(source_path: ?Str, out_snapshot: ?*?*Snapshot) c_int { + const out = out_snapshot orelse return -1; + out.* = null; + const path = source_path orelse return -1; + if (path[0] == 0) return -1; + const snapshot_ptr: ?*Snapshot = interop.mallocN(Snapshot, 1); + const snapshot = snapshot_ptr orelse return -1; + snapshot.* = .{}; + if (v5Open(snapshot, path) != 0) { + cbm_zova_v5_snapshot_close(snapshot); + return -1; + } + out.* = snapshot; + return 0; +} + +pub export fn cbm_zova_v5_snapshot_workspace_count(snapshot: ?*const Snapshot) c_int { + const s = snapshot orelse return -1; + return @intCast(s.workspaces.len); +} + +pub export fn cbm_zova_v5_snapshot_input_at(snapshot: ?*const Snapshot, index: c_int, out_active_generation: ?*i64) ?*const Input { + if (out_active_generation) |out| out.* = 0; + const s = snapshot orelse return null; + if (index < 0 or index >= s.workspaces.len) return null; + const workspace = &s.workspaces[@intCast(index)]; + if (out_active_generation) |out| out.* = workspace.active_generation; + return &workspace.input; +} diff --git a/src/zova/cbm_zova_writer_gate.c b/src/zova/cbm_zova_writer_gate.c deleted file mode 100644 index 7bebec90..00000000 --- a/src/zova/cbm_zova_writer_gate.c +++ /dev/null @@ -1,110 +0,0 @@ -#include "zova/cbm_zova_writer_gate.h" - -#include "foundation/constants.h" -#include "foundation/compat.h" -#include "foundation/compat_fs.h" -#include "foundation/compat_thread.h" -#include "foundation/file_lock.h" -#include "foundation/platform.h" - -#include -#include -#include -#include - -#ifdef _WIN32 -#include -#else -#include -#endif - -static cbm_mutex_t gate_mutex; -static _Atomic uint64_t gate_waiters; - -#ifdef _WIN32 -static INIT_ONCE gate_once = INIT_ONCE_STATIC_INIT; -static BOOL CALLBACK init_gate(PINIT_ONCE once, PVOID parameter, PVOID *context) { - (void)once; - (void)parameter; - (void)context; - cbm_mutex_init(&gate_mutex); - return TRUE; -} -static void ensure_gate_initialized(void) { - InitOnceExecuteOnce(&gate_once, init_gate, NULL, NULL); -} -#else -static pthread_once_t gate_once = PTHREAD_ONCE_INIT; -static void init_gate(void) { - cbm_mutex_init(&gate_mutex); -} -static void ensure_gate_initialized(void) { - (void)pthread_once(&gate_once, init_gate); -} -#endif - -static int acquire_process_lock(const char *zova_path, - cbm_zova_writer_guard_t *guard, - bool wait) { - int n = snprintf(guard->lock_path, sizeof(guard->lock_path), - "%s.writer.lock", zova_path); - if (n <= 0 || (size_t)n >= sizeof(guard->lock_path)) return -1; - cbm_file_lock_t lock; - if (cbm_file_lock_acquire(guard->lock_path, CBM_FILE_LOCK_EXCLUSIVE, wait, &lock) != 0) - return -1; - guard->handle = lock.handle; - return 0; -} - -int cbm_zova_writer_guard_acquire(const char *zova_path, - cbm_zova_writer_guard_t *guard) { - if (!zova_path || !zova_path[0] || !guard) return -1; - memset(guard, 0, sizeof(*guard)); - guard->handle = -1; - ensure_gate_initialized(); - atomic_fetch_add_explicit(&gate_waiters, 1, memory_order_relaxed); - cbm_mutex_lock(&gate_mutex); - atomic_fetch_sub_explicit(&gate_waiters, 1, memory_order_relaxed); - guard->mutex_held = true; - if (acquire_process_lock(zova_path, guard, true) != 0) { - guard->mutex_held = false; - cbm_mutex_unlock(&gate_mutex); - return -1; - } - return 0; -} - -int cbm_zova_writer_guard_try_acquire(const char *zova_path, - cbm_zova_writer_guard_t *guard) { - if (!zova_path || !zova_path[0] || !guard) return -1; - memset(guard, 0, sizeof(*guard)); - guard->handle = -1; - ensure_gate_initialized(); -#ifdef _WIN32 - if (!TryEnterCriticalSection(&gate_mutex.cs)) return -1; -#else - if (pthread_mutex_trylock(&gate_mutex.mtx) != 0) return -1; -#endif - guard->mutex_held = true; - if (acquire_process_lock(zova_path, guard, false) != 0) { - guard->mutex_held = false; - cbm_mutex_unlock(&gate_mutex); - return -1; - } - return 0; -} - -void cbm_zova_writer_guard_release(cbm_zova_writer_guard_t *guard) { - if (!guard) return; - cbm_file_lock_t lock = {.handle = guard->handle}; - cbm_file_lock_release(&lock); - guard->handle = -1; - if (guard->mutex_held) { - guard->mutex_held = false; - cbm_mutex_unlock(&gate_mutex); - } -} - -uint64_t cbm_zova_writer_gate_waiter_count(void) { - return atomic_load_explicit(&gate_waiters, memory_order_relaxed); -} diff --git a/src/zova/cbm_zova_writer_gate.zig b/src/zova/cbm_zova_writer_gate.zig new file mode 100644 index 00000000..18bd2e45 --- /dev/null +++ b/src/zova/cbm_zova_writer_gate.zig @@ -0,0 +1,92 @@ +//! cbm_zova_writer_gate.zig — One writer at a time per cbm.zova: a process-wide +//! mutex for threads, plus an exclusive lock file for other processes. +//! +//! Implements src/zova/cbm_zova_writer_gate.h. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); +const interop = @import("../foundation/interop.zig"); +const compat_thread = @import("../foundation/compat_thread.zig"); + +const Str = interop.Str; +const Guard = c.cbm_zova_writer_guard_t; + +const is_windows = builtin.os.tag == .windows; + +var gate_mutex: c.cbm_mutex_t = undefined; +var gate_waiters = std.atomic.Value(u64).init(0); +var gate_once: c.cbm_once_t = compat_thread.once_init; + +fn initGate() callconv(.c) void { + c.cbm_mutex_init(&gate_mutex); +} + +fn ensureGateInitialized() void { + c.cbm_once(&gate_once, initGate); +} + +fn acquireProcessLock(zova_path: Str, guard: *Guard, wait: bool) c_int { + const n = interop.bprint(&guard.lock_path, "{s}.writer.lock", .{std.mem.span(zova_path)}); + if (n == 0 or n >= guard.lock_path.len) return -1; + var lock: c.cbm_file_lock_t = undefined; + if (c.cbm_file_lock_acquire(&guard.lock_path, c.CBM_FILE_LOCK_EXCLUSIVE, wait, &lock) != 0) + return -1; + guard.handle = lock.handle; + return 0; +} + +pub export fn cbm_zova_writer_guard_acquire(zova_path: ?Str, guard: ?*Guard) c_int { + const path = zova_path orelse return -1; + const g = guard orelse return -1; + if (path[0] == 0) return -1; + g.* = std.mem.zeroes(Guard); + g.handle = -1; + ensureGateInitialized(); + _ = gate_waiters.fetchAdd(1, .monotonic); + c.cbm_mutex_lock(&gate_mutex); + _ = gate_waiters.fetchSub(1, .monotonic); + g.mutex_held = true; + if (acquireProcessLock(path, g, true) != 0) { + g.mutex_held = false; + c.cbm_mutex_unlock(&gate_mutex); + return -1; + } + return 0; +} + +pub export fn cbm_zova_writer_guard_try_acquire(zova_path: ?Str, guard: ?*Guard) c_int { + const path = zova_path orelse return -1; + const g = guard orelse return -1; + if (path[0] == 0) return -1; + g.* = std.mem.zeroes(Guard); + g.handle = -1; + ensureGateInitialized(); + if (is_windows) { + if (c.TryEnterCriticalSection(&gate_mutex.cs) == 0) return -1; + } else { + if (c.pthread_mutex_trylock(&gate_mutex.mtx) != 0) return -1; + } + g.mutex_held = true; + if (acquireProcessLock(path, g, false) != 0) { + g.mutex_held = false; + c.cbm_mutex_unlock(&gate_mutex); + return -1; + } + return 0; +} + +pub export fn cbm_zova_writer_guard_release(guard: ?*Guard) void { + const g = guard orelse return; + var lock = c.cbm_file_lock_t{ .handle = g.handle }; + c.cbm_file_lock_release(&lock); + g.handle = -1; + if (g.mutex_held) { + g.mutex_held = false; + c.cbm_mutex_unlock(&gate_mutex); + } +} + +pub export fn cbm_zova_writer_gate_waiter_count() u64 { + return gate_waiters.load(.monotonic); +} From fab7a0603a85fe899802db106a7d5c040b4d7937 Mon Sep 17 00:00:00 2001 From: ata-sesli Date: Tue, 10 Nov 2026 21:10:18 +0300 Subject: [PATCH 4/9] refactor(zova): port cbm_zova_plugin from C to Zig src/zova/cbm_zova_plugin.c (1,296 lines) becomes src/zova/cbm_zova_plugin.zig (1,206 lines). The header stays the ABI and cbm_zova_plugin_descriptor() keeps its symbol and signature. The plugin implements the Zova plugin SDK callbacks, so zova_plugin.h is now translated together with zova.h when CBM_WITH_ZOVA=1. Without Zova the descriptor export returns NULL, as the C file did. Layout follows the C file: session and trace helpers, the cbm_trace, cbm_edges and cbm_cypher table functions, then registration. Differences in form, not behavior: - Trace rows are sorted with std.mem.sortUnstable instead of qsort. The comparator is a total order (the node id breaks every tie), so the result is the same. - The three table-function registrations share one tableOperation helper instead of three near-identical struct literals. - cypher run cleanup is one defer block instead of `goto done`. No behavior differences intended. Verified with the pinned Zova: zova_plugin 8, zova_bridge 4, zova 39, zova_operations 59, zova_incremental_native 1, zova_c_sql_functions 3, mcp 176, cli 132 (all equal to the baseline on main). Signed-off-by: ata-sesli --- Makefile.cbm | 8 +- build.zig | 1 - src/cbm_zig.zig | 1 + src/cbm_zig_c.h | 4 +- src/zova/cbm_zova_plugin.c | 1296 ---------------------------------- src/zova/cbm_zova_plugin.zig | 1206 +++++++++++++++++++++++++++++++ 6 files changed, 1214 insertions(+), 1302 deletions(-) delete mode 100644 src/zova/cbm_zova_plugin.c create mode 100644 src/zova/cbm_zova_plugin.zig diff --git a/Makefile.cbm b/Makefile.cbm index 296fb3e7..2ed1b664 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -155,8 +155,7 @@ ZOVA_SRCS = \ src/zova/cbm_zova.c \ src/zova/cbm_zova_publish_model.c \ src/zova/cbm_zova_operations.c \ - src/zova/cbm_zova_repository.c \ - src/zova/cbm_zova_plugin.c + src/zova/cbm_zova_repository.c # MCP server module: implemented in Zig (src/mcp/*.zig, part of libcbm_zig) MCP_SRCS = @@ -487,7 +486,8 @@ CBM_ZIG_SRCS = $(CBM_ZIG_SRC) src/cbm_zig_c.h src/cli_zig/cbm_cli_zig.zig src/cl src/watcher/watcher.zig src/watcher/watcher.h \ src/zova/cbm_zova_bridge_stub.zig src/zova/cbm_zova_coordination.zig \ src/zova/cbm_zova_delta.zig src/zova/cbm_zova_edge_payload.zig \ - src/zova/cbm_zova_route.zig src/zova/cbm_zova_v5_snapshot.zig \ + src/zova/cbm_zova_plugin.zig src/zova/cbm_zova_route.zig \ + src/zova/cbm_zova_v5_snapshot.zig \ src/zova/cbm_zova_writer_gate.zig \ $(wildcard src/zova/*.h) # The Zig modules see CBM_WITH_ZOVA and, with Zova, the SDK header too. @@ -495,7 +495,7 @@ CBM_ZIG_C_INCLUDES = -Isrc -Ivendored -Ivendored/sqlite3 -Ivendored/mimalloc/inc -I$(CBM_DIR) -I$(TS_INCLUDE) ifeq ($(CBM_WITH_ZOVA),1) CBM_ZIG_C_INCLUDES += -DCBM_WITH_ZOVA=1 -I$(ZOVA_ROOT)/include -CBM_ZIG_SRCS += $(ZOVA_ROOT)/include/zova.h +CBM_ZIG_SRCS += $(ZOVA_ROOT)/include/zova.h $(ZOVA_ROOT)/include/zova_plugin.h else CBM_ZIG_C_INCLUDES += -DCBM_WITH_ZOVA=0 endif diff --git a/build.zig b/build.zig index 4447700e..65094f5e 100644 --- a/build.zig +++ b/build.zig @@ -540,7 +540,6 @@ const native_sources = [_][]const u8{ "src/zova/cbm_zova_publish_model.c", "src/zova/cbm_zova_operations.c", "src/zova/cbm_zova_repository.c", - "src/zova/cbm_zova_plugin.c", "src/pipeline/fqn.c", "src/pipeline/path_alias.c", "src/pipeline/laravel_routing.c", diff --git a/src/cbm_zig.zig b/src/cbm_zig.zig index 3360078a..ae6c47fd 100644 --- a/src/cbm_zig.zig +++ b/src/cbm_zig.zig @@ -68,6 +68,7 @@ comptime { checkExports(@import("zova/cbm_zova_coordination.zig")); checkExports(@import("zova/cbm_zova_delta.zig")); checkExports(@import("zova/cbm_zova_edge_payload.zig")); + checkExports(@import("zova/cbm_zova_plugin.zig")); checkExports(@import("zova/cbm_zova_route.zig")); checkExports(@import("zova/cbm_zova_v5_snapshot.zig")); checkExports(@import("zova/cbm_zova_writer_gate.zig")); diff --git a/src/cbm_zig_c.h b/src/cbm_zig_c.h index be2adefe..2e7d8d0f 100644 --- a/src/cbm_zig_c.h +++ b/src/cbm_zig_c.h @@ -7,7 +7,7 @@ * translate-c cannot handle never blocks an unrelated module. * * The build passes -DCBM_WITH_ZOVA=0|1 to translate-c. With Zova, the SDK - * header zova.h is translated too, and the Zig Zova modules compile their + * headers zova.h and zova_plugin.h are translated too, and the Zig Zova modules compile their * real implementation instead of the no-Zova stubs. */ #ifndef CBM_ZIG_C_H @@ -101,6 +101,7 @@ #include "zova/cbm_zova_delta.h" #include "zova/cbm_zova_edge_payload.h" #include "zova/cbm_zova_operations.h" +#include "zova/cbm_zova_plugin.h" #include "zova/cbm_zova_publish_model.h" #include "zova/cbm_zova_repository.h" #include "zova/cbm_zova_route.h" @@ -108,6 +109,7 @@ #include "zova/cbm_zova_writer_gate.h" #if CBM_WITH_ZOVA #include +#include #endif #include #include diff --git a/src/zova/cbm_zova_plugin.c b/src/zova/cbm_zova_plugin.c deleted file mode 100644 index 6280b42b..00000000 --- a/src/zova/cbm_zova_plugin.c +++ /dev/null @@ -1,1296 +0,0 @@ -#include "zova/cbm_zova_plugin.h" - -#include - -#ifndef CBM_WITH_ZOVA -#define CBM_WITH_ZOVA 0 -#endif - -#if !CBM_WITH_ZOVA - -const struct zova_plugin_descriptor_v1 *cbm_zova_plugin_descriptor(void) { - return NULL; -} - -#else - -#include "cypher/cypher.h" -#include "store/store.h" -#include "zova/cbm_zova.h" -#include "zova/cbm_zova_edge_payload.h" -#include "zova_plugin.h" - -#include -#include -#include -#include -#include - -enum { - PLUGIN_EDGE_TYPES_MAX = 16, - PLUGIN_NAME_MAX = 255, - PLUGIN_FRONTIER_BATCH = 1024, - PLUGIN_HYDRATE_BATCH = 128, - PLUGIN_PAGE_ROWS = 4096, - PLUGIN_PAGE_BYTES = 1024 * 1024, - PLUGIN_MAX_RESULTS = 100000, - PLUGIN_TEXT_COLUMNS = 5, - PLUGIN_NODE_COLUMNS = 8, - PLUGIN_TRACE_ARGUMENTS = 6, - PLUGIN_TRACE_COLUMNS = 9, - PLUGIN_NEIGHBOR_COLUMNS = 6, - PLUGIN_PAYLOAD_BATCH = 256, - PLUGIN_PAYLOAD_COLUMNS = 4, - PLUGIN_EDGES_ARGUMENTS = 4, - PLUGIN_EDGES_COLUMNS = 6, - PLUGIN_SCAN_PAGE = 2048, - PLUGIN_CYPHER_ARGUMENTS = 3, - PLUGIN_CYPHER_COLUMNS = 2, -}; - -#define PLUGIN_NODE_SELECT \ - "SELECT n.zova_node_key,n.label,n.name,n.qualified_name,f.file_path,n.start_line," \ - "n.end_line,n.properties FROM cbm_nodes_v1 n JOIN cbm_files_v1 f " \ - "ON f.file_key=n.file_key " - -/* The host services one hook or operation callback may use. */ -typedef struct { - void *connection; - const zova_plugin_data_service_v1 *data; - const zova_plugin_query_service_v1 *query; -} plugin_session_t; - -/* One reached node. text holds label, name, qualified_name, file_path and - * properties back to back. */ -typedef struct { - int64_t key; - int64_t start_line; - int64_t end_line; - int32_t hop; - char *text; - uint64_t off[PLUGIN_TEXT_COLUMNS]; - uint64_t len[PLUGIN_TEXT_COLUMNS]; -} plugin_row_t; - -enum { ROW_LABEL, ROW_NAME, ROW_QN, ROW_FILE, ROW_PROPERTIES }; - -typedef struct { - plugin_row_t *rows; - size_t count; - size_t cap; - size_t next; - /* Visited node keys in discovery order; a level is a contiguous slice. */ - int64_t *keys; - size_t key_count; - size_t key_cap; - /* Open-addressing set over keys (0 = empty; node keys are positive). */ - int64_t *set; - size_t set_cap; - int32_t level; - uint64_t last_ordinal; -} plugin_trace_t; - -static int32_t plugin_service(const zova_plugin_host_v1 *host, void *connection, uint32_t id, - uint32_t size, const void **out) { - *out = NULL; - if (!host || host->struct_size < sizeof(zova_plugin_service_host_v1)) { - return ZOVA_PLUGIN_UNSUPPORTED; - } - return ((const zova_plugin_service_host_v1 *)host)->get_service(connection, id, 1, size, out); -} - -static int32_t plugin_session_open(const zova_plugin_host_v1 *host, void *connection, - plugin_session_t *session) { - const void *data = NULL; - const void *query = NULL; - int32_t status = plugin_service(host, connection, ZOVA_PLUGIN_SERVICE_DATA, - sizeof(zova_plugin_data_service_v1), &data); - if (status == ZOVA_PLUGIN_OK) { - status = plugin_service(host, connection, ZOVA_PLUGIN_SERVICE_QUERY, - sizeof(zova_plugin_query_service_v1), &query); - } - *session = (plugin_session_t){connection, data, query}; - return status; -} - -static void plugin_trace_free(plugin_trace_t *trace) { - if (!trace) return; - for (size_t i = 0; i < trace->count; i++) free(trace->rows[i].text); - free(trace->rows); - free(trace->keys); - free(trace->set); - free(trace); -} - -static size_t plugin_set_slot(const int64_t *set, size_t cap, int64_t key) { - size_t slot = (size_t)((uint64_t)key * UINT64_C(0x9E3779B97F4A7C15)) & (cap - 1); - while (set[slot] != 0 && set[slot] != key) slot = (slot + 1) & (cap - 1); - return slot; -} - -/* Returns 1 when key was added, 0 when already present, -1 on allocation failure. */ -static int plugin_visit(plugin_trace_t *trace, int64_t key) { - if ((trace->key_count + 1) * 2 > trace->set_cap) { - size_t cap = trace->set_cap ? trace->set_cap * 2 : 1024; - int64_t *set = calloc(cap, sizeof(*set)); - if (!set) return -1; - for (size_t i = 0; i < trace->key_count; i++) { - set[plugin_set_slot(set, cap, trace->keys[i])] = trace->keys[i]; - } - free(trace->set); - trace->set = set; - trace->set_cap = cap; - } - size_t slot = plugin_set_slot(trace->set, trace->set_cap, key); - if (trace->set[slot] == key) return 0; - if (trace->key_count == trace->key_cap) { - size_t cap = trace->key_cap ? trace->key_cap * 2 : 1024; - int64_t *keys = realloc(trace->keys, cap * sizeof(*keys)); - if (!keys) return -1; - trace->keys = keys; - trace->key_cap = cap; - } - trace->set[slot] = key; - trace->keys[trace->key_count++] = key; - return 1; -} - -/* NEIGHBORS_KEYED row: ordinal, input_key, edge_key, neighbor_key, edge_type, order. */ -static int32_t ZOVA_PLUGIN_CALL plugin_neighbor_row(void *user_data, - const zova_plugin_value_v1 *values, - uint64_t count) { - plugin_trace_t *trace = user_data; - if (count != PLUGIN_NEIGHBOR_COLUMNS || values[0].kind != ZOVA_PLUGIN_VALUE_INTEGER || - values[0].integer < 0 || values[3].kind != ZOVA_PLUGIN_VALUE_INTEGER || - values[3].integer <= 0) { - return ZOVA_PLUGIN_ERROR; - } - trace->last_ordinal = (uint64_t)values[0].integer; - return plugin_visit(trace, values[3].integer) < 0 ? ZOVA_PLUGIN_OUT_OF_MEMORY : ZOVA_PLUGIN_OK; -} - -/* Reads every matching neighbor of keys[0..count), following the keyed cursor. */ -static int32_t plugin_expand(const plugin_session_t *session, plugin_trace_t *trace, - zova_plugin_data_request_v1 *request, size_t first, size_t count) { - zova_plugin_cursor_v1 after = {0}; - while (count > 0) { - zova_plugin_data_page_v1 page = {0}; - /* The row callback may grow trace->keys; re-read the pointer per page. */ - request->keys = trace->keys + first; - request->key_count = count; - request->after = after; - trace->last_ordinal = 0; - int32_t status = session->data->read(session->connection, request, &page); - if (status != ZOVA_PLUGIN_OK) return status; - if (!page.has_more) break; - if (page.rows == 0 || trace->last_ordinal >= count) return ZOVA_PLUGIN_ERROR; - first += (size_t)trace->last_ordinal; - count -= (size_t)trace->last_ordinal; - after = page.next; - } - return ZOVA_PLUGIN_OK; -} - -/* Node row: zova_node_key, label, name, qualified_name, file_path, start_line, - * end_line, properties. */ -static int32_t ZOVA_PLUGIN_CALL plugin_node_row(void *user_data, - const zova_plugin_value_v1 *values, - uint64_t count) { - static const int text_column[PLUGIN_TEXT_COLUMNS] = {1, 2, 3, 4, 7}; - plugin_trace_t *trace = user_data; - if (count != PLUGIN_NODE_COLUMNS || values[0].kind != ZOVA_PLUGIN_VALUE_INTEGER || - values[5].kind != ZOVA_PLUGIN_VALUE_INTEGER || - values[6].kind != ZOVA_PLUGIN_VALUE_INTEGER) { - return ZOVA_PLUGIN_ERROR; - } - if (trace->count == trace->cap) { - size_t cap = trace->cap ? trace->cap * 2 : 64; - plugin_row_t *rows = realloc(trace->rows, cap * sizeof(*rows)); - if (!rows) return ZOVA_PLUGIN_OUT_OF_MEMORY; - trace->rows = rows; - trace->cap = cap; - } - plugin_row_t row = { - .key = values[0].integer, - .start_line = values[5].integer, - .end_line = values[6].integer, - .hop = trace->level, - }; - uint64_t total = 0; - for (int i = 0; i < PLUGIN_TEXT_COLUMNS; i++) { - const zova_plugin_value_v1 *value = &values[text_column[i]]; - if (value->kind != ZOVA_PLUGIN_VALUE_TEXT) return ZOVA_PLUGIN_ERROR; - row.off[i] = total; - row.len[i] = value->bytes_len; - total += value->bytes_len; - } - row.text = malloc(total ? (size_t)total : 1); - if (!row.text) return ZOVA_PLUGIN_OUT_OF_MEMORY; - for (int i = 0; i < PLUGIN_TEXT_COLUMNS; i++) { - if (row.len[i]) { - memcpy(row.text + row.off[i], values[text_column[i]].bytes, (size_t)row.len[i]); - } - } - trace->rows[trace->count++] = row; - return ZOVA_PLUGIN_OK; -} - -static void plugin_rows_truncate(plugin_trace_t *trace, size_t count) { - while (trace->count > count) free(trace->rows[--trace->count].text); -} - -/* Loads the cbm_nodes_v1 rows of keys[first..first+count). Graph nodes without a - * row are skipped. A batch over the query byte budget is retried in halves. */ -static int32_t plugin_hydrate(const plugin_session_t *session, plugin_trace_t *trace, - size_t first, size_t count) { - static const char head[] = PLUGIN_NODE_SELECT "WHERE n.zova_node_key IN ("; - char sql[sizeof(head) + PLUGIN_HYDRATE_BATCH * 2 + 2]; - zova_plugin_value_v1 parameters[PLUGIN_HYDRATE_BATCH]; - size_t batch = PLUGIN_HYDRATE_BATCH; - while (count > 0) { - size_t n = count < batch ? count : batch; - size_t len = sizeof(head) - 1; - memcpy(sql, head, len); - memset(parameters, 0, n * sizeof(parameters[0])); - for (size_t i = 0; i < n; i++) { - if (i > 0) sql[len++] = ','; - sql[len++] = '?'; - parameters[i].kind = ZOVA_PLUGIN_VALUE_INTEGER; - parameters[i].integer = trace->keys[first + i]; - } - sql[len++] = ')'; - zova_plugin_query_request_v1 request = { - .struct_size = sizeof(request), - .sql = sql, - .sql_len = len, - .parameters = parameters, - .parameter_count = n, - .row_limit = n, - .byte_limit = PLUGIN_PAGE_BYTES, - .row = plugin_node_row, - .user_data = trace, - }; - size_t before = trace->count; - int32_t status = session->query->query(session->connection, &request); - if (status == ZOVA_PLUGIN_LIMIT && n > 1) { - plugin_rows_truncate(trace, before); - batch = n / 2; - continue; - } - if (status != ZOVA_PLUGIN_OK) return status; - first += n; - count -= n; - } - return ZOVA_PLUGIN_OK; -} - -static int plugin_bytes_compare(const plugin_row_t *a, const plugin_row_t *b, int column) { - uint64_t shared = a->len[column] < b->len[column] ? a->len[column] : b->len[column]; - int order = shared ? memcmp(a->text + a->off[column], b->text + b->off[column], (size_t)shared) - : 0; - if (order != 0) return order; - return (a->len[column] > b->len[column]) - (a->len[column] < b->len[column]); -} - -static int plugin_row_compare(const void *left, const void *right) { - static const int order_columns[] = {ROW_FILE, ROW_QN, ROW_LABEL}; - const plugin_row_t *a = left; - const plugin_row_t *b = right; - if (a->hop != b->hop) return (a->hop > b->hop) - (a->hop < b->hop); - for (size_t i = 0; i < sizeof(order_columns) / sizeof(order_columns[0]); i++) { - int order = plugin_bytes_compare(a, b, order_columns[i]); - if (order != 0) return order; - } - if (a->start_line != b->start_line) return (a->start_line > b->start_line) ? 1 : -1; - if (a->end_line != b->end_line) return (a->end_line > b->end_line) ? 1 : -1; - return (a->key > b->key) - (a->key < b->key); -} - -/* Splits "A,B" into at most PLUGIN_EDGE_TYPES_MAX non-empty names. An empty - * list yields 0; a malformed one -1. */ -static int plugin_edge_types(const zova_plugin_value_v1 *value, zova_plugin_bytes_v1 *out) { - int count = 0; - if (value->bytes_len == 0) return 0; - uint64_t start = 0; - for (uint64_t i = 0; i <= value->bytes_len; i++) { - if (i < value->bytes_len && value->bytes[i] != ',') continue; - if (i == start || i - start > PLUGIN_NAME_MAX || count == PLUGIN_EDGE_TYPES_MAX) return -1; - out[count++] = (zova_plugin_bytes_v1){value->bytes + start, i - start}; - start = i + 1; - } - return count; -} - -static int plugin_text_equals(const zova_plugin_value_v1 *value, const char *text) { - size_t len = strlen(text); - return value->bytes_len == len && memcmp(value->bytes, text, len) == 0; -} - -/* No names means every edge type. */ -static void plugin_request_edge_types(zova_plugin_data_request_v1 *request, - const zova_plugin_bytes_v1 *edge_types, int count) { - if (count == 1) { - request->edge_type = edge_types[0]; - } else if (count > 1) { - request->ids = edge_types; - request->id_count = (uint64_t)count; - } -} - -static int plugin_direction(const zova_plugin_value_v1 *value, uint32_t *out) { - if (plugin_text_equals(value, "outbound")) { - *out = ZOVA_PLUGIN_OUTGOING; - } else if (plugin_text_equals(value, "inbound")) { - *out = ZOVA_PLUGIN_INCOMING; - } else { - return -1; - } - return 0; -} - - -/* Walks breadth-first from start_key and leaves the reached nodes in - * trace->rows, ordered and cut to max_results. */ -static int32_t plugin_trace_run(const plugin_session_t *session, plugin_trace_t *trace, - zova_plugin_bytes_v1 graph, int64_t start_key, - uint32_t direction, const zova_plugin_bytes_v1 *edge_types, - int edge_type_count, int64_t max_depth, int64_t max_results) { - if (plugin_visit(trace, start_key) < 0) return ZOVA_PLUGIN_OUT_OF_MEMORY; - zova_plugin_data_request_v1 request = { - .struct_size = sizeof(request), - .operation = ZOVA_PLUGIN_GRAPH_NEIGHBORS_KEYED, - .name = graph, - .direction = direction, - .row_limit = PLUGIN_PAGE_ROWS, - .byte_limit = PLUGIN_PAGE_BYTES, - .row = plugin_neighbor_row, - .user_data = trace, - }; - plugin_request_edge_types(&request, edge_types, edge_type_count); - - size_t level_start = 0; - size_t level_end = 1; - for (int64_t hop = 1; hop <= max_depth && level_start < level_end; hop++) { - for (size_t first = level_start; first < level_end; first += PLUGIN_FRONTIER_BATCH) { - size_t count = level_end - first; - if (count > PLUGIN_FRONTIER_BATCH) count = PLUGIN_FRONTIER_BATCH; - int32_t status = plugin_expand(session, trace, &request, first, count); - if (status != ZOVA_PLUGIN_OK) return status; - } - level_start = level_end; - level_end = trace->key_count; - trace->level = (int32_t)hop; - int32_t status = plugin_hydrate(session, trace, level_start, level_end - level_start); - if (status != ZOVA_PLUGIN_OK) return status; - /* Later levels sort after this one, so they cannot enter the result. */ - if (trace->count >= (size_t)max_results) break; - } - if (trace->count > 1) qsort(trace->rows, trace->count, sizeof(*trace->rows), plugin_row_compare); - plugin_rows_truncate(trace, (size_t)max_results); - return ZOVA_PLUGIN_OK; -} - -static int32_t ZOVA_PLUGIN_CALL plugin_trace_open(const zova_plugin_host_v1 *host, - void *connection, void *state, - const zova_plugin_value_v1 *arguments, - uint64_t argument_count, void **out_cursor) { - (void)state; - *out_cursor = NULL; - if (argument_count != PLUGIN_TRACE_ARGUMENTS) return ZOVA_PLUGIN_INVALID_ARGUMENT; - zova_plugin_bytes_v1 graph = {arguments[0].bytes, arguments[0].bytes_len}; - int64_t start_key = arguments[1].integer; - int64_t max_depth = arguments[4].integer; - int64_t max_results = arguments[5].integer; - zova_plugin_bytes_v1 edge_types[PLUGIN_EDGE_TYPES_MAX]; - int edge_type_count = plugin_edge_types(&arguments[3], edge_types); - uint32_t direction; - if (plugin_direction(&arguments[2], &direction) != 0 || graph.len == 0 || - graph.len > PLUGIN_NAME_MAX || start_key <= 0 || edge_type_count <= 0 || max_depth < 0 || - max_results <= 0 || max_results > PLUGIN_MAX_RESULTS) { - return ZOVA_PLUGIN_INVALID_ARGUMENT; - } - plugin_session_t session; - int32_t status = plugin_session_open(host, connection, &session); - if (status != ZOVA_PLUGIN_OK) return status; - plugin_trace_t *trace = calloc(1, sizeof(*trace)); - if (!trace) return ZOVA_PLUGIN_OUT_OF_MEMORY; - *out_cursor = trace; - return plugin_trace_run(&session, trace, graph, start_key, direction, edge_types, - edge_type_count, max_depth, max_results); -} - -static int32_t ZOVA_PLUGIN_CALL plugin_trace_next(const zova_plugin_host_v1 *host, - void *connection, void *cursor, - zova_plugin_row_v1 row, void *row_context, - uint32_t *out_has_row) { - (void)host; - (void)connection; - plugin_trace_t *trace = cursor; - *out_has_row = 0; - if (trace->next >= trace->count) return ZOVA_PLUGIN_OK; - const plugin_row_t *item = &trace->rows[trace->next++]; - zova_plugin_value_v1 values[PLUGIN_TRACE_COLUMNS] = {0}; - values[0] = (zova_plugin_value_v1){.kind = ZOVA_PLUGIN_VALUE_INTEGER, .integer = item->key}; - values[1] = (zova_plugin_value_v1){.kind = ZOVA_PLUGIN_VALUE_INTEGER, .integer = item->hop}; - static const int text_column[PLUGIN_TEXT_COLUMNS] = {2, 3, 4, 5, 8}; - for (int i = 0; i < PLUGIN_TEXT_COLUMNS; i++) { - values[text_column[i]] = (zova_plugin_value_v1){ - .kind = ZOVA_PLUGIN_VALUE_TEXT, - .bytes = (const uint8_t *)item->text + item->off[i], - .bytes_len = item->len[i], - }; - } - values[6] = (zova_plugin_value_v1){.kind = ZOVA_PLUGIN_VALUE_INTEGER, - .integer = item->start_line}; - values[7] = (zova_plugin_value_v1){.kind = ZOVA_PLUGIN_VALUE_INTEGER, - .integer = item->end_line}; - *out_has_row = 1; - return row(row_context, values, PLUGIN_TRACE_COLUMNS); -} - -static void ZOVA_PLUGIN_CALL plugin_trace_close(void *cursor) { - plugin_trace_free(cursor); -} - -/* ── edges ─────────────────────────────────────────────────────── */ - -/* One adjacency entry; type is copied because row bytes are borrowed. */ -typedef struct { - int64_t edge_key; - int64_t neighbor_key; - char *type; - uint64_t type_len; -} plugin_neighbor_t; - -/* One cbm edge. text holds local_name then properties. A graph edge carries - * one or more of these in its payload. */ -typedef struct { - size_t neighbor; - char *text; - uint64_t local_name_len; - uint64_t properties_len; -} plugin_edge_t; - -typedef struct { - plugin_neighbor_t *neighbors; - size_t neighbor_count; - size_t neighbor_cap; - plugin_edge_t *edges; - size_t edge_count; - size_t edge_cap; - size_t next; - int64_t node_key; - uint32_t direction; - /* First neighbor of the payload batch being read, and the one being decoded. */ - size_t batch_first; - size_t batch_count; - size_t current; -} plugin_edges_t; - -static void plugin_edges_truncate(plugin_edges_t *state, size_t count) { - while (state->edge_count > count) free(state->edges[--state->edge_count].text); -} - -static void plugin_edges_free(plugin_edges_t *state) { - if (!state) return; - plugin_edges_truncate(state, 0); - for (size_t i = 0; i < state->neighbor_count; i++) free(state->neighbors[i].type); - free(state->neighbors); - free(state->edges); - free(state); -} - -static int32_t ZOVA_PLUGIN_CALL plugin_edges_neighbor_row(void *user_data, - const zova_plugin_value_v1 *values, - uint64_t count) { - plugin_edges_t *state = user_data; - if (count != PLUGIN_NEIGHBOR_COLUMNS || values[2].kind != ZOVA_PLUGIN_VALUE_INTEGER || - values[3].kind != ZOVA_PLUGIN_VALUE_INTEGER || values[4].kind != ZOVA_PLUGIN_VALUE_TEXT) { - return ZOVA_PLUGIN_ERROR; - } - if (state->neighbor_count == state->neighbor_cap) { - size_t cap = state->neighbor_cap ? state->neighbor_cap * 2 : 64; - plugin_neighbor_t *neighbors = realloc(state->neighbors, cap * sizeof(*neighbors)); - if (!neighbors) return ZOVA_PLUGIN_OUT_OF_MEMORY; - state->neighbors = neighbors; - state->neighbor_cap = cap; - } - char *type = malloc(values[4].bytes_len ? (size_t)values[4].bytes_len : 1); - if (!type) return ZOVA_PLUGIN_OUT_OF_MEMORY; - if (values[4].bytes_len) memcpy(type, values[4].bytes, (size_t)values[4].bytes_len); - state->neighbors[state->neighbor_count++] = (plugin_neighbor_t){ - .edge_key = values[2].integer, - .neighbor_key = values[3].integer, - .type = type, - .type_len = values[4].bytes_len, - }; - return ZOVA_PLUGIN_OK; -} - -static int plugin_edges_record(const cbm_zova_edge_payload_record_t *record, void *context) { - plugin_edges_t *state = context; - if (state->edge_count == state->edge_cap) { - size_t cap = state->edge_cap ? state->edge_cap * 2 : 64; - plugin_edge_t *edges = realloc(state->edges, cap * sizeof(*edges)); - if (!edges) return -1; - state->edges = edges; - state->edge_cap = cap; - } - size_t total = record->local_name.len + record->properties.len; - char *text = malloc(total ? total : 1); - if (!text) return -1; - if (record->local_name.len) memcpy(text, record->local_name.data, record->local_name.len); - if (record->properties.len) { - memcpy(text + record->local_name.len, record->properties.data, record->properties.len); - } - state->edges[state->edge_count++] = (plugin_edge_t){ - .neighbor = state->current, - .text = text, - .local_name_len = record->local_name.len, - .properties_len = record->properties.len, - }; - return 0; -} - -/* EDGE_PAYLOADS_GET row: ordinal, found, input_key, payload. */ -static int32_t ZOVA_PLUGIN_CALL plugin_edges_payload_row(void *user_data, - const zova_plugin_value_v1 *values, - uint64_t count) { - plugin_edges_t *state = user_data; - if (count != PLUGIN_PAYLOAD_COLUMNS || values[0].kind != ZOVA_PLUGIN_VALUE_INTEGER || - values[0].integer < 0 || (uint64_t)values[0].integer >= state->batch_count || - values[1].kind != ZOVA_PLUGIN_VALUE_INTEGER || values[1].integer != 1 || - values[3].kind != ZOVA_PLUGIN_VALUE_BLOB) { - return ZOVA_PLUGIN_ERROR; - } - state->current = state->batch_first + (size_t)values[0].integer; - if (values[2].integer != state->neighbors[state->current].edge_key) return ZOVA_PLUGIN_ERROR; - return cbm_zova_edge_payload_visit(values[3].bytes, (size_t)values[3].bytes_len, - plugin_edges_record, state, NULL) == 0 - ? ZOVA_PLUGIN_OK - : ZOVA_PLUGIN_ERROR; -} - -/* Decodes the payloads of every collected neighbor edge. A batch over the byte - * budget is retried in halves. */ -static int32_t plugin_edges_payloads(const plugin_session_t *session, plugin_edges_t *state, - zova_plugin_bytes_v1 graph) { - int64_t keys[PLUGIN_PAYLOAD_BATCH]; - size_t batch = PLUGIN_PAYLOAD_BATCH; - size_t first = 0; - while (first < state->neighbor_count) { - size_t n = state->neighbor_count - first; - if (n > batch) n = batch; - for (size_t i = 0; i < n; i++) keys[i] = state->neighbors[first + i].edge_key; - zova_plugin_data_request_v1 request = { - .struct_size = sizeof(request), - .operation = ZOVA_PLUGIN_GRAPH_EDGE_PAYLOADS_GET, - .name = graph, - .keys = keys, - .key_count = n, - .row_limit = n, - .byte_limit = PLUGIN_PAGE_BYTES, - .row = plugin_edges_payload_row, - .user_data = state, - }; - zova_plugin_data_page_v1 page = {0}; - size_t before = state->edge_count; - state->batch_first = first; - state->batch_count = n; - int32_t status = session->data->read(session->connection, &request, &page); - if (status == ZOVA_PLUGIN_LIMIT && n > 1) { - plugin_edges_truncate(state, before); - batch = n / 2; - continue; - } - if (status != ZOVA_PLUGIN_OK) return status; - if (page.rows != n) return ZOVA_PLUGIN_ERROR; - first += n; - } - return ZOVA_PLUGIN_OK; -} - -/* Collects the adjacency of node_key into state->neighbors. */ -static int32_t plugin_edges_neighbors(const plugin_session_t *session, plugin_edges_t *state, - zova_plugin_bytes_v1 graph, int64_t node_key, - uint32_t direction, const zova_plugin_bytes_v1 *edge_types, - int edge_type_count) { - state->node_key = node_key; - state->direction = direction; - zova_plugin_data_request_v1 request = { - .struct_size = sizeof(request), - .operation = ZOVA_PLUGIN_GRAPH_NEIGHBORS_KEYED, - .name = graph, - .keys = &state->node_key, - .key_count = 1, - .direction = direction, - .row_limit = PLUGIN_PAGE_ROWS, - .byte_limit = PLUGIN_PAGE_BYTES, - .row = plugin_edges_neighbor_row, - .user_data = state, - }; - plugin_request_edge_types(&request, edge_types, edge_type_count); - for (;;) { - zova_plugin_data_page_v1 page = {0}; - int32_t status = session->data->read(session->connection, &request, &page); - if (status != ZOVA_PLUGIN_OK) return status; - if (!page.has_more) return ZOVA_PLUGIN_OK; - if (page.rows == 0) return ZOVA_PLUGIN_ERROR; - request.after = page.next; - } -} - -static int32_t ZOVA_PLUGIN_CALL plugin_edges_open(const zova_plugin_host_v1 *host, - void *connection, void *unused, - const zova_plugin_value_v1 *arguments, - uint64_t argument_count, void **out_cursor) { - (void)unused; - *out_cursor = NULL; - if (argument_count != PLUGIN_EDGES_ARGUMENTS) return ZOVA_PLUGIN_INVALID_ARGUMENT; - zova_plugin_bytes_v1 graph = {arguments[0].bytes, arguments[0].bytes_len}; - zova_plugin_bytes_v1 edge_types[PLUGIN_EDGE_TYPES_MAX]; - int edge_type_count = plugin_edge_types(&arguments[3], edge_types); - uint32_t direction; - if (plugin_direction(&arguments[2], &direction) != 0 || graph.len == 0 || - graph.len > PLUGIN_NAME_MAX || arguments[1].integer <= 0 || edge_type_count < 0) { - return ZOVA_PLUGIN_INVALID_ARGUMENT; - } - plugin_session_t session; - int32_t status = plugin_session_open(host, connection, &session); - if (status != ZOVA_PLUGIN_OK) return status; - plugin_edges_t *state = calloc(1, sizeof(*state)); - if (!state) return ZOVA_PLUGIN_OUT_OF_MEMORY; - *out_cursor = state; - status = plugin_edges_neighbors(&session, state, graph, arguments[1].integer, direction, - edge_types, edge_type_count); - return status == ZOVA_PLUGIN_OK ? plugin_edges_payloads(&session, state, graph) : status; -} - -static int32_t ZOVA_PLUGIN_CALL plugin_edges_next(const zova_plugin_host_v1 *host, - void *connection, void *cursor, - zova_plugin_row_v1 row, void *row_context, - uint32_t *out_has_row) { - (void)host; - (void)connection; - plugin_edges_t *state = cursor; - *out_has_row = 0; - if (state->next >= state->edge_count) return ZOVA_PLUGIN_OK; - const plugin_edge_t *edge = &state->edges[state->next++]; - const plugin_neighbor_t *neighbor = &state->neighbors[edge->neighbor]; - int outgoing = state->direction == ZOVA_PLUGIN_OUTGOING; - const uint8_t *text = (const uint8_t *)edge->text; - zova_plugin_value_v1 values[PLUGIN_EDGES_COLUMNS] = { - {.kind = ZOVA_PLUGIN_VALUE_INTEGER, .integer = neighbor->edge_key}, - {.kind = ZOVA_PLUGIN_VALUE_INTEGER, - .integer = outgoing ? state->node_key : neighbor->neighbor_key}, - {.kind = ZOVA_PLUGIN_VALUE_INTEGER, - .integer = outgoing ? neighbor->neighbor_key : state->node_key}, - {.kind = ZOVA_PLUGIN_VALUE_TEXT, .bytes = (const uint8_t *)neighbor->type, - .bytes_len = neighbor->type_len}, - {.kind = ZOVA_PLUGIN_VALUE_TEXT, .bytes = text, .bytes_len = edge->local_name_len}, - {.kind = ZOVA_PLUGIN_VALUE_TEXT, .bytes = text + edge->local_name_len, - .bytes_len = edge->properties_len}, - }; - *out_has_row = 1; - return row(row_context, values, PLUGIN_EDGES_COLUMNS); -} - -static void ZOVA_PLUGIN_CALL plugin_edges_close(void *cursor) { - plugin_edges_free(cursor); -} - -/* ── cypher ────────────────────────────────────────────────────── */ - -/* Store backend for one workspace: answers the reads the Cypher engine makes - * from the plugin services. Node ids are Zova node keys and edge ids Zova edge - * keys, so id(n) and id(r) differ from the host store's hashed ids. */ -typedef struct { - plugin_session_t session; - zova_plugin_bytes_v1 graph; - char graph_name[CBM_ZOVA_WORKSPACE_ID_MAX + 32]; - int64_t workspace_key; - char *project; -} plugin_store_t; - -static char *plugin_dup(const char *bytes, uint64_t len) { - char *copy = malloc((size_t)len + 1); - if (!copy) return NULL; - if (len) memcpy(copy, bytes, (size_t)len); - copy[len] = '\0'; - return copy; -} - -static void plugin_node_clear(cbm_node_t *node) { - free((char *)node->project); - free((char *)node->label); - free((char *)node->name); - free((char *)node->qualified_name); - free((char *)node->file_path); - free((char *)node->properties_json); - memset(node, 0, sizeof(*node)); -} - -static int plugin_node_from_row(const plugin_store_t *store, const plugin_row_t *row, - cbm_node_t *out) { - *out = (cbm_node_t){ - .id = row->key, - .project = plugin_dup(store->project, strlen(store->project)), - .label = plugin_dup(row->text + row->off[ROW_LABEL], row->len[ROW_LABEL]), - .name = plugin_dup(row->text + row->off[ROW_NAME], row->len[ROW_NAME]), - .qualified_name = plugin_dup(row->text + row->off[ROW_QN], row->len[ROW_QN]), - .file_path = plugin_dup(row->text + row->off[ROW_FILE], row->len[ROW_FILE]), - .start_line = (int)row->start_line, - .end_line = (int)row->end_line, - .properties_json = plugin_dup(row->text + row->off[ROW_PROPERTIES], - row->len[ROW_PROPERTIES]), - }; - if (out->project && out->label && out->name && out->qualified_name && out->file_path && - out->properties_json) { - return 0; - } - plugin_node_clear(out); - return -1; -} - -static zova_plugin_value_v1 plugin_text_value(const char *bytes, uint64_t len) { - return (zova_plugin_value_v1){.kind = ZOVA_PLUGIN_VALUE_TEXT, - .bytes = (const uint8_t *)bytes, .bytes_len = len}; -} - -/* Workspace nodes ordered by name, qualified_name, key, optionally of one label. - * Read in keyset pages; a page over the byte budget is retried at half size. */ -static int plugin_store_search(void *ctx, const cbm_search_params_t *params, - cbm_search_output_t *out) { - static const char sql[] = PLUGIN_NODE_SELECT - "WHERE n.workspace_key=?1 AND (?2='' OR n.label=?2) AND " - "(?3=0 OR (n.name,n.qualified_name,n.zova_node_key)>(?4,?5,?6)) " - "ORDER BY n.name,n.qualified_name,n.zova_node_key LIMIT ?7"; - const plugin_store_t *store = ctx; - memset(out, 0, sizeof(*out)); - /* The engine only scans by label; refuse filters this backend does not apply. */ - if (params->name_pattern || params->qn_pattern || params->file_pattern || - params->relationship || params->exclude_labels || params->exclude_entry_points || - params->min_degree >= 0 || params->max_degree >= 0 || params->offset != 0) { - return CBM_STORE_ERR; - } - size_t limit = params->limit > 0 ? (size_t)params->limit : 10; - const char *label = params->label ? params->label : ""; - plugin_trace_t *rows = calloc(1, sizeof(*rows)); - if (!rows) return CBM_STORE_ERR; - size_t page = PLUGIN_SCAN_PAGE; - int rc = CBM_STORE_OK; - while (rows->count < limit) { - size_t n = limit - rows->count < page ? limit - rows->count : page; - const plugin_row_t *last = rows->count ? &rows->rows[rows->count - 1] : NULL; - zova_plugin_value_v1 parameters[] = { - {.kind = ZOVA_PLUGIN_VALUE_INTEGER, .integer = store->workspace_key}, - plugin_text_value(label, strlen(label)), - {.kind = ZOVA_PLUGIN_VALUE_INTEGER, .integer = last != NULL}, - plugin_text_value(last ? last->text + last->off[ROW_NAME] : "", - last ? last->len[ROW_NAME] : 0), - plugin_text_value(last ? last->text + last->off[ROW_QN] : "", - last ? last->len[ROW_QN] : 0), - {.kind = ZOVA_PLUGIN_VALUE_INTEGER, .integer = last ? last->key : 0}, - {.kind = ZOVA_PLUGIN_VALUE_INTEGER, .integer = (int64_t)n}, - }; - zova_plugin_query_request_v1 request = { - .struct_size = sizeof(request), - .sql = sql, - .sql_len = sizeof(sql) - 1, - .parameters = parameters, - .parameter_count = sizeof(parameters) / sizeof(parameters[0]), - .row_limit = n, - .byte_limit = PLUGIN_PAGE_BYTES, - .row = plugin_node_row, - .user_data = rows, - }; - size_t before = rows->count; - int32_t status = store->session.query->query(store->session.connection, &request); - if (status == ZOVA_PLUGIN_LIMIT && n > 1) { - plugin_rows_truncate(rows, before); - page = n / 2; - continue; - } - if (status != ZOVA_PLUGIN_OK) { - rc = CBM_STORE_ERR; - break; - } - if (rows->count - before < n) break; - } - cbm_search_result_t *results = - rc == CBM_STORE_OK && rows->count ? calloc(rows->count, sizeof(*results)) : NULL; - if (rc == CBM_STORE_OK && rows->count && !results) rc = CBM_STORE_ERR; - for (size_t i = 0; rc == CBM_STORE_OK && i < rows->count; i++) { - if (plugin_node_from_row(store, &rows->rows[i], &results[i].node) != 0) { - rc = CBM_STORE_ERR; - break; - } - out->count = (int)i + 1; - } - out->results = results; - out->total = out->count; - if (rc != CBM_STORE_OK) cbm_store_search_free(out); - plugin_trace_free(rows); - return rc; -} - -static int plugin_store_find_node_by_id(void *ctx, int64_t id, cbm_node_t *out) { - const plugin_store_t *store = ctx; - memset(out, 0, sizeof(*out)); - if (id <= 0) return CBM_STORE_NOT_FOUND; - plugin_trace_t *rows = calloc(1, sizeof(*rows)); - if (!rows) return CBM_STORE_ERR; - int rc = CBM_STORE_ERR; - if (plugin_visit(rows, id) == 1 && - plugin_hydrate(&store->session, rows, 0, 1) == ZOVA_PLUGIN_OK) { - rc = rows->count == 0 ? CBM_STORE_NOT_FOUND - : plugin_node_from_row(store, &rows->rows[0], out) == 0 - ? CBM_STORE_OK - : CBM_STORE_ERR; - } - plugin_trace_free(rows); - return rc; -} - -static int plugin_store_direction(const char *direction, uint32_t *out) { - if (!direction || strcmp(direction, "outbound") == 0) { - *out = ZOVA_PLUGIN_OUTGOING; - } else if (strcmp(direction, "inbound") == 0) { - *out = ZOVA_PLUGIN_INCOMING; - } else { - return -1; - } - return 0; -} - -static int plugin_store_find_edges(void *ctx, int64_t node_id, const char *direction, - const char *type, cbm_edge_t **out, int *count) { - const plugin_store_t *store = ctx; - *out = NULL; - *count = 0; - uint32_t zova_direction; - if (node_id <= 0 || plugin_store_direction(direction, &zova_direction) != 0) { - return CBM_STORE_ERR; - } - zova_plugin_bytes_v1 edge_type = {(const uint8_t *)type, type ? strlen(type) : 0}; - if (edge_type.len > PLUGIN_NAME_MAX) return CBM_STORE_ERR; - plugin_edges_t *state = calloc(1, sizeof(*state)); - if (!state) return CBM_STORE_ERR; - int rc = CBM_STORE_ERR; - cbm_edge_t *edges = NULL; - if (plugin_edges_neighbors(&store->session, state, store->graph, node_id, zova_direction, - &edge_type, edge_type.len ? 1 : 0) != ZOVA_PLUGIN_OK || - plugin_edges_payloads(&store->session, state, store->graph) != ZOVA_PLUGIN_OK) { - goto done; - } - edges = state->edge_count ? calloc(state->edge_count, sizeof(*edges)) : NULL; - if (state->edge_count && !edges) goto done; - for (size_t i = 0; i < state->edge_count; i++) { - const plugin_edge_t *edge = &state->edges[i]; - const plugin_neighbor_t *neighbor = &state->neighbors[edge->neighbor]; - int outgoing = zova_direction == ZOVA_PLUGIN_OUTGOING; - edges[i] = (cbm_edge_t){ - .id = neighbor->edge_key, - .project = plugin_dup(store->project, strlen(store->project)), - .source_id = outgoing ? node_id : neighbor->neighbor_key, - .target_id = outgoing ? neighbor->neighbor_key : node_id, - .type = plugin_dup(neighbor->type, neighbor->type_len), - .properties_json = plugin_dup(edge->text + edge->local_name_len, - edge->properties_len), - }; - *count = (int)i + 1; - if (!edges[i].project || !edges[i].type || !edges[i].properties_json) goto done; - } - rc = CBM_STORE_OK; -done: - if (rc == CBM_STORE_OK) { - *out = edges; - } else { - cbm_store_free_edges(edges, *count); - *count = 0; - } - plugin_edges_free(state); - return rc; -} - -/* CALLS edges in and out, like the host store. */ -static int plugin_store_node_degree(void *ctx, int64_t node_id, int *in_deg, int *out_deg) { - static const zova_plugin_bytes_v1 calls = {(const uint8_t *)"CALLS", 5}; - const plugin_store_t *store = ctx; - int *degrees[] = {out_deg, in_deg}; - const uint32_t directions[] = {ZOVA_PLUGIN_OUTGOING, ZOVA_PLUGIN_INCOMING}; - if (node_id <= 0) return CBM_STORE_ERR; - for (int i = 0; i < 2; i++) { - plugin_edges_t *state = calloc(1, sizeof(*state)); - if (!state) return CBM_STORE_ERR; - int32_t status = plugin_edges_neighbors(&store->session, state, store->graph, node_id, - directions[i], &calls, 1); - *degrees[i] = (int)state->neighbor_count; - plugin_edges_free(state); - if (status != ZOVA_PLUGIN_OK) return CBM_STORE_ERR; - } - return CBM_STORE_OK; -} - -static int plugin_store_bfs(void *ctx, int64_t start_id, const char *direction, - const char **edge_types, int edge_type_count, int max_depth, - int max_results, cbm_traverse_result_t *out) { - static const char *const default_types[] = {"CALLS"}; - const plugin_store_t *store = ctx; - memset(out, 0, sizeof(*out)); - uint32_t zova_direction; - /* The host store walks one direction per call too. */ - if (plugin_store_direction(direction, &zova_direction) != 0) return CBM_STORE_NOT_FOUND; - if (edge_type_count <= 0) { - edge_types = (const char **)default_types; - edge_type_count = 1; - } - if (edge_type_count > PLUGIN_EDGE_TYPES_MAX || max_depth < 0 || max_results <= 0 || - max_results > PLUGIN_MAX_RESULTS) { - return CBM_STORE_ERR; - } - zova_plugin_bytes_v1 types[PLUGIN_EDGE_TYPES_MAX]; - for (int i = 0; i < edge_type_count; i++) { - types[i] = (zova_plugin_bytes_v1){(const uint8_t *)edge_types[i], strlen(edge_types[i])}; - if (types[i].len == 0 || types[i].len > PLUGIN_NAME_MAX) return CBM_STORE_ERR; - } - int rc = plugin_store_find_node_by_id(ctx, start_id, &out->root); - if (rc != CBM_STORE_OK) return rc; - plugin_trace_t *trace = calloc(1, sizeof(*trace)); - rc = CBM_STORE_ERR; - if (trace && plugin_trace_run(&store->session, trace, store->graph, start_id, zova_direction, - types, edge_type_count, max_depth, max_results) == - ZOVA_PLUGIN_OK) { - out->visited = trace->count ? calloc(trace->count, sizeof(*out->visited)) : NULL; - rc = trace->count && !out->visited ? CBM_STORE_ERR : CBM_STORE_OK; - for (size_t i = 0; rc == CBM_STORE_OK && i < trace->count; i++) { - if (plugin_node_from_row(store, &trace->rows[i], &out->visited[i].node) != 0) { - rc = CBM_STORE_ERR; - break; - } - out->visited[i].hop = trace->rows[i].hop; - out->visited_count = (int)i + 1; - } - } - plugin_trace_free(trace); - if (rc != CBM_STORE_OK) cbm_store_traverse_free(out); - return rc; -} - -static const cbm_store_backend_t plugin_store_backend = { - .search = plugin_store_search, - .find_node_by_id = plugin_store_find_node_by_id, - .find_edges = plugin_store_find_edges, - .node_degree = plugin_store_node_degree, - .bfs = plugin_store_bfs, -}; - -/* Workspace row: workspace_key, project. */ -static int32_t ZOVA_PLUGIN_CALL plugin_workspace_row(void *user_data, - const zova_plugin_value_v1 *values, - uint64_t count) { - plugin_store_t *store = user_data; - if (count != 2 || values[0].kind != ZOVA_PLUGIN_VALUE_INTEGER || - values[1].kind != ZOVA_PLUGIN_VALUE_TEXT) { - return ZOVA_PLUGIN_ERROR; - } - store->workspace_key = values[0].integer; - store->project = plugin_dup((const char *)values[1].bytes, values[1].bytes_len); - return store->project ? ZOVA_PLUGIN_OK : ZOVA_PLUGIN_OUT_OF_MEMORY; -} - -/* Result rows as (kind, cells) pairs, in emission order. */ -typedef struct { - const char **kinds; - char **cells; - size_t count; - size_t next; -} plugin_cypher_t; - -static void plugin_cypher_free(plugin_cypher_t *cursor) { - if (!cursor) return; - for (size_t i = 0; i < cursor->count; i++) free(cursor->cells[i]); - free(cursor->kinds); - free(cursor->cells); - free(cursor); -} - -/* Takes ownership of cells; NULL cells reports an allocation failure. */ -static int plugin_cypher_push(plugin_cypher_t *cursor, const char *kind, char *cells) { - if (!cells) return -1; - cursor->kinds[cursor->count] = kind; - cursor->cells[cursor->count++] = cells; - return 0; -} - -static char *plugin_json_array(const char *const *values, int count) { - yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); - if (!doc) return NULL; - yyjson_mut_val *array = yyjson_mut_arr(doc); - yyjson_mut_doc_set_root(doc, array); - for (int i = 0; i < count; i++) { - if (values[i]) { - yyjson_mut_arr_add_str(doc, array, values[i]); - } else { - yyjson_mut_arr_add_null(doc, array); - } - } - char *json = yyjson_mut_write(doc, 0, NULL); - yyjson_mut_doc_free(doc); - return json; -} - -static int32_t plugin_cypher_run(const plugin_session_t *session, - const zova_plugin_value_v1 *arguments, - plugin_cypher_t *cursor) { - static const char workspace_sql[] = - "SELECT r.workspace_key,p.project FROM cbm_workspace_registry r " - "JOIN cbm_projects_v1 p ON p.workspace_key=r.workspace_key WHERE r.workspace_id=?1"; - plugin_store_t store = {.session = *session}; - char *workspace_id = plugin_dup((const char *)arguments[0].bytes, arguments[0].bytes_len); - char *query = plugin_dup((const char *)arguments[1].bytes, arguments[1].bytes_len); - cbm_store_t *handle = NULL; - cbm_cypher_result_t result = {0}; - int32_t status = ZOVA_PLUGIN_OUT_OF_MEMORY; - /* Up to three rows besides the result rows: columns, warning, error. */ - cursor->kinds = calloc(3, sizeof(*cursor->kinds)); - cursor->cells = calloc(3, sizeof(*cursor->cells)); - if (!workspace_id || !query || !cursor->kinds || !cursor->cells) goto done; - if (strlen(workspace_id) != arguments[0].bytes_len || strlen(query) != arguments[1].bytes_len || - cbm_zova_workspace_graph_name(workspace_id, store.graph_name, - sizeof(store.graph_name)) != 0) { - status = ZOVA_PLUGIN_INVALID_ARGUMENT; - goto done; - } - store.graph = (zova_plugin_bytes_v1){(const uint8_t *)store.graph_name, - strlen(store.graph_name)}; - zova_plugin_query_request_v1 request = { - .struct_size = sizeof(request), - .sql = workspace_sql, - .sql_len = sizeof(workspace_sql) - 1, - .parameters = &arguments[0], - .parameter_count = 1, - .row_limit = 1, - .byte_limit = PLUGIN_PAGE_BYTES, - .row = plugin_workspace_row, - .user_data = &store, - }; - status = session->query->query(session->connection, &request); - if (status != ZOVA_PLUGIN_OK) goto done; - status = ZOVA_PLUGIN_OUT_OF_MEMORY; - if (!store.project) { - status = plugin_cypher_push(cursor, "error", plugin_dup("workspace not found", 19)) == 0 - ? ZOVA_PLUGIN_OK - : ZOVA_PLUGIN_OUT_OF_MEMORY; - goto done; - } - handle = cbm_store_open_backend(&plugin_store_backend, &store); - if (!handle) goto done; - if (cbm_cypher_execute(handle, query, store.project, (int)arguments[2].integer, &result) != - 0) { - const char *message = result.error ? result.error : "query failed"; - if (plugin_cypher_push(cursor, "error", plugin_dup(message, strlen(message))) == 0) { - status = ZOVA_PLUGIN_OK; - } - goto done; - } - size_t capacity = (size_t)result.row_count + 3; - const char **kinds = realloc(cursor->kinds, capacity * sizeof(*kinds)); - if (!kinds) goto done; - cursor->kinds = kinds; - char **cells = realloc(cursor->cells, capacity * sizeof(*cells)); - if (!cells) goto done; - cursor->cells = cells; - if (plugin_cypher_push(cursor, "columns", - plugin_json_array(result.columns, result.col_count)) != 0) { - goto done; - } - for (int i = 0; i < result.row_count; i++) { - if (plugin_cypher_push(cursor, "row", - plugin_json_array(result.rows[i], result.col_count)) != 0) { - goto done; - } - } - if (result.warning && plugin_cypher_push(cursor, "warning", - plugin_dup(result.warning, - strlen(result.warning))) != 0) { - goto done; - } - status = ZOVA_PLUGIN_OK; -done: - cbm_cypher_result_free(&result); - cbm_store_close(handle); - free(store.project); - free(workspace_id); - free(query); - return status; -} - -static int32_t ZOVA_PLUGIN_CALL plugin_cypher_open(const zova_plugin_host_v1 *host, - void *connection, void *unused, - const zova_plugin_value_v1 *arguments, - uint64_t argument_count, void **out_cursor) { - (void)unused; - *out_cursor = NULL; - if (argument_count != PLUGIN_CYPHER_ARGUMENTS || arguments[1].bytes_len == 0 || - arguments[2].integer < 0 || arguments[2].integer > INT32_MAX) { - return ZOVA_PLUGIN_INVALID_ARGUMENT; - } - plugin_session_t session; - int32_t status = plugin_session_open(host, connection, &session); - if (status != ZOVA_PLUGIN_OK) return status; - plugin_cypher_t *cursor = calloc(1, sizeof(*cursor)); - if (!cursor) return ZOVA_PLUGIN_OUT_OF_MEMORY; - *out_cursor = cursor; - return plugin_cypher_run(&session, arguments, cursor); -} - -static int32_t ZOVA_PLUGIN_CALL plugin_cypher_next(const zova_plugin_host_v1 *host, - void *connection, void *raw, - zova_plugin_row_v1 row, void *row_context, - uint32_t *out_has_row) { - (void)host; - (void)connection; - plugin_cypher_t *cursor = raw; - *out_has_row = 0; - if (cursor->next >= cursor->count) return ZOVA_PLUGIN_OK; - const char *kind = cursor->kinds[cursor->next]; - const char *cells = cursor->cells[cursor->next++]; - zova_plugin_value_v1 values[PLUGIN_CYPHER_COLUMNS] = { - plugin_text_value(kind, strlen(kind)), - plugin_text_value(cells, strlen(cells)), - }; - *out_has_row = 1; - return row(row_context, values, PLUGIN_CYPHER_COLUMNS); -} - -static void ZOVA_PLUGIN_CALL plugin_cypher_close(void *cursor) { - plugin_cypher_free(cursor); -} - -#define PLUGIN_COLUMN(text, kind) {{(const uint8_t *)(text), sizeof(text) - 1}, (kind), 0} - -static int32_t ZOVA_PLUGIN_CALL plugin_register_sql(const zova_plugin_host_v1 *host, - void *connection) { - static const zova_plugin_operation_column_v1 arguments[PLUGIN_TRACE_ARGUMENTS] = { - PLUGIN_COLUMN("graph", ZOVA_PLUGIN_VALUE_TEXT), - PLUGIN_COLUMN("start_key", ZOVA_PLUGIN_VALUE_INTEGER), - PLUGIN_COLUMN("direction", ZOVA_PLUGIN_VALUE_TEXT), - PLUGIN_COLUMN("edge_types", ZOVA_PLUGIN_VALUE_TEXT), - PLUGIN_COLUMN("max_depth", ZOVA_PLUGIN_VALUE_INTEGER), - PLUGIN_COLUMN("max_results", ZOVA_PLUGIN_VALUE_INTEGER), - }; - static const zova_plugin_operation_column_v1 columns[PLUGIN_TRACE_COLUMNS] = { - PLUGIN_COLUMN("node_key", ZOVA_PLUGIN_VALUE_INTEGER), - PLUGIN_COLUMN("hop", ZOVA_PLUGIN_VALUE_INTEGER), - PLUGIN_COLUMN("label", ZOVA_PLUGIN_VALUE_TEXT), - PLUGIN_COLUMN("name", ZOVA_PLUGIN_VALUE_TEXT), - PLUGIN_COLUMN("qualified_name", ZOVA_PLUGIN_VALUE_TEXT), - PLUGIN_COLUMN("file_path", ZOVA_PLUGIN_VALUE_TEXT), - PLUGIN_COLUMN("start_line", ZOVA_PLUGIN_VALUE_INTEGER), - PLUGIN_COLUMN("end_line", ZOVA_PLUGIN_VALUE_INTEGER), - PLUGIN_COLUMN("properties", ZOVA_PLUGIN_VALUE_TEXT), - }; - const void *raw = NULL; - int32_t status = plugin_service(host, connection, ZOVA_PLUGIN_SERVICE_OPERATIONS, - sizeof(zova_plugin_operation_service_v1), &raw); - if (status != ZOVA_PLUGIN_OK) return status; - zova_plugin_operation_v1 operation = { - .struct_size = sizeof(operation), - .kind = ZOVA_PLUGIN_OPERATION_TABLE, - .flags = ZOVA_PLUGIN_OPERATION_EXACT | ZOVA_PLUGIN_OPERATION_ORDERED, - .name = {(const uint8_t *)"trace_path", sizeof("trace_path") - 1}, - .arguments = arguments, - .argument_count = PLUGIN_TRACE_ARGUMENTS, - .column_count = PLUGIN_TRACE_COLUMNS, - .columns = columns, - .open = plugin_trace_open, - .next = plugin_trace_next, - .close = plugin_trace_close, - }; - const zova_plugin_operation_service_v1 *operations = raw; - status = operations->register_operation(connection, &operation); - if (status != ZOVA_PLUGIN_OK) return status; - - static const zova_plugin_operation_column_v1 edges_arguments[PLUGIN_EDGES_ARGUMENTS] = { - PLUGIN_COLUMN("graph", ZOVA_PLUGIN_VALUE_TEXT), - PLUGIN_COLUMN("node_key", ZOVA_PLUGIN_VALUE_INTEGER), - PLUGIN_COLUMN("direction", ZOVA_PLUGIN_VALUE_TEXT), - PLUGIN_COLUMN("edge_types", ZOVA_PLUGIN_VALUE_TEXT), - }; - static const zova_plugin_operation_column_v1 edges_columns[PLUGIN_EDGES_COLUMNS] = { - PLUGIN_COLUMN("edge_key", ZOVA_PLUGIN_VALUE_INTEGER), - PLUGIN_COLUMN("source_key", ZOVA_PLUGIN_VALUE_INTEGER), - PLUGIN_COLUMN("target_key", ZOVA_PLUGIN_VALUE_INTEGER), - PLUGIN_COLUMN("edge_type", ZOVA_PLUGIN_VALUE_TEXT), - PLUGIN_COLUMN("local_name", ZOVA_PLUGIN_VALUE_TEXT), - PLUGIN_COLUMN("properties", ZOVA_PLUGIN_VALUE_TEXT), - }; - zova_plugin_operation_v1 edges = { - .struct_size = sizeof(edges), - .kind = ZOVA_PLUGIN_OPERATION_TABLE, - .flags = ZOVA_PLUGIN_OPERATION_EXACT | ZOVA_PLUGIN_OPERATION_ORDERED, - .name = {(const uint8_t *)"edges", sizeof("edges") - 1}, - .arguments = edges_arguments, - .argument_count = PLUGIN_EDGES_ARGUMENTS, - .column_count = PLUGIN_EDGES_COLUMNS, - .columns = edges_columns, - .open = plugin_edges_open, - .next = plugin_edges_next, - .close = plugin_edges_close, - }; - status = operations->register_operation(connection, &edges); - if (status != ZOVA_PLUGIN_OK) return status; - - static const zova_plugin_operation_column_v1 cypher_arguments[PLUGIN_CYPHER_ARGUMENTS] = { - PLUGIN_COLUMN("workspace_id", ZOVA_PLUGIN_VALUE_TEXT), - PLUGIN_COLUMN("query", ZOVA_PLUGIN_VALUE_TEXT), - PLUGIN_COLUMN("max_rows", ZOVA_PLUGIN_VALUE_INTEGER), - }; - static const zova_plugin_operation_column_v1 cypher_columns[PLUGIN_CYPHER_COLUMNS] = { - PLUGIN_COLUMN("kind", ZOVA_PLUGIN_VALUE_TEXT), - PLUGIN_COLUMN("cells", ZOVA_PLUGIN_VALUE_TEXT), - }; - zova_plugin_operation_v1 cypher = { - .struct_size = sizeof(cypher), - .kind = ZOVA_PLUGIN_OPERATION_TABLE, - .flags = ZOVA_PLUGIN_OPERATION_EXACT | ZOVA_PLUGIN_OPERATION_ORDERED, - .name = {(const uint8_t *)"cypher", sizeof("cypher") - 1}, - .arguments = cypher_arguments, - .argument_count = PLUGIN_CYPHER_ARGUMENTS, - .column_count = PLUGIN_CYPHER_COLUMNS, - .columns = cypher_columns, - .open = plugin_cypher_open, - .next = plugin_cypher_next, - .close = plugin_cypher_close, - }; - return operations->register_operation(connection, &cypher); -} - -/* The plugin owns no tables. data_version names the cbm tables and graph layout - * it reads (cbm_nodes_v1, cbm_files_v1, cbm_graph_); a newer plugin - * keeps opening older installs by listing them in accepted_data_versions. */ -static const zova_plugin_compatibility_descriptor_v1 plugin_descriptor = { - .base = { - .base = { - .struct_size = sizeof(zova_plugin_compatibility_descriptor_v1), - .abi_version = ZOVA_PLUGIN_ABI_V1, - .flags = ZOVA_PLUGIN_REQUIRES_QUERY_V1 | ZOVA_PLUGIN_REQUIRES_DATA_V1 | - ZOVA_PLUGIN_REQUIRES_OPERATIONS_V1 | ZOVA_PLUGIN_HAS_DATA_COMPATIBILITY_V1, - .name = "cbm", - .version = "0.1.0", - .storage_prefix = "_zova_ext_cbm_", - .zova_abi_min = "1.3.0", - .capabilities = "", - .register_sql = plugin_register_sql, - }, - }, - .data_version = "1.0.0", - .accepted_data_versions = "1.0.0", -}; - -const struct zova_plugin_descriptor_v1 *cbm_zova_plugin_descriptor(void) { - return &plugin_descriptor.base.base; -} - -#endif diff --git a/src/zova/cbm_zova_plugin.zig b/src/zova/cbm_zova_plugin.zig new file mode 100644 index 00000000..0ddc6b49 --- /dev/null +++ b/src/zova/cbm_zova_plugin.zig @@ -0,0 +1,1206 @@ +//! cbm_zova_plugin.zig — Statically linked Zova plugin "cbm": the read-only +//! table operations trace_path, edges and cypher. +//! +//! Implements src/zova/cbm_zova_plugin.h, which documents the operations. +//! Without Zova the descriptor is NULL and nothing else here is compiled. + +const std = @import("std"); +const c = @import("c"); +const interop = @import("../foundation/interop.zig"); + +const with_zova = c.CBM_WITH_ZOVA != 0; + +const Value = c.zova_plugin_value_v1; +const Bytes = c.zova_plugin_bytes_v1; +const Host = c.zova_plugin_host_v1; +const DataRequest = c.zova_plugin_data_request_v1; +const DataPage = c.zova_plugin_data_page_v1; +const QueryRequest = c.zova_plugin_query_request_v1; +const Column = c.zova_plugin_operation_column_v1; +const Operation = c.zova_plugin_operation_v1; +const PayloadRecord = c.cbm_zova_edge_payload_record_t; + +const OK: i32 = c.ZOVA_PLUGIN_OK; +const ERROR: i32 = c.ZOVA_PLUGIN_ERROR; +const OUT_OF_MEMORY: i32 = c.ZOVA_PLUGIN_OUT_OF_MEMORY; +const INVALID_ARGUMENT: i32 = c.ZOVA_PLUGIN_INVALID_ARGUMENT; +const LIMIT: i32 = c.ZOVA_PLUGIN_LIMIT; + +const INTEGER: u32 = c.ZOVA_PLUGIN_VALUE_INTEGER; +const TEXT: u32 = c.ZOVA_PLUGIN_VALUE_TEXT; +const BLOB: u32 = c.ZOVA_PLUGIN_VALUE_BLOB; +const OUTGOING: u32 = c.ZOVA_PLUGIN_OUTGOING; +const INCOMING: u32 = c.ZOVA_PLUGIN_INCOMING; + +const STORE_OK: c_int = c.CBM_STORE_OK; +const STORE_ERR: c_int = c.CBM_STORE_ERR; +const STORE_NOT_FOUND: c_int = c.CBM_STORE_NOT_FOUND; + +const PLUGIN_EDGE_TYPES_MAX = 16; +const PLUGIN_NAME_MAX = 255; +const PLUGIN_FRONTIER_BATCH = 1024; +const PLUGIN_HYDRATE_BATCH = 128; +const PLUGIN_PAGE_ROWS = 4096; +const PLUGIN_PAGE_BYTES = 1024 * 1024; +const PLUGIN_MAX_RESULTS = 100000; +const PLUGIN_TEXT_COLUMNS = 5; +const PLUGIN_NODE_COLUMNS = 8; +const PLUGIN_TRACE_ARGUMENTS = 6; +const PLUGIN_TRACE_COLUMNS = 9; +const PLUGIN_NEIGHBOR_COLUMNS = 6; +const PLUGIN_PAYLOAD_BATCH = 256; +const PLUGIN_PAYLOAD_COLUMNS = 4; +const PLUGIN_EDGES_ARGUMENTS = 4; +const PLUGIN_EDGES_COLUMNS = 6; +const PLUGIN_SCAN_PAGE = 2048; +const PLUGIN_CYPHER_ARGUMENTS = 3; +const PLUGIN_CYPHER_COLUMNS = 2; + +const PLUGIN_NODE_SELECT = + "SELECT n.zova_node_key,n.label,n.name,n.qualified_name,f.file_path,n.start_line," ++ + "n.end_line,n.properties FROM cbm_nodes_v1 n JOIN cbm_files_v1 f " ++ + "ON f.file_key=n.file_key "; + +/// The host services one hook or operation callback may use. +const Session = struct { + connection: ?*anyopaque, + data: [*c]const c.zova_plugin_data_service_v1, + query: [*c]const c.zova_plugin_query_service_v1, +}; + +const ROW_LABEL = 0; +const ROW_NAME = 1; +const ROW_QN = 2; +const ROW_FILE = 3; +const ROW_PROPERTIES = 4; + +/// One reached node. text holds label, name, qualified_name, file_path and +/// properties back to back. +const Row = struct { + key: i64, + start_line: i64, + end_line: i64, + hop: i32, + text: [*c]u8, + off: [PLUGIN_TEXT_COLUMNS]u64, + len: [PLUGIN_TEXT_COLUMNS]u64, + + fn column(row: *const Row, index: usize) []const u8 { + return (row.text + @as(usize, @intCast(row.off[index])))[0..@intCast(row.len[index])]; + } +}; + +const Trace = struct { + rows: [*c]Row = null, + count: usize = 0, + cap: usize = 0, + next: usize = 0, + /// Visited node keys in discovery order; a level is a contiguous slice. + keys: [*c]i64 = null, + key_count: usize = 0, + key_cap: usize = 0, + /// Open-addressing set over keys (0 = empty; node keys are positive). + set: [*c]i64 = null, + set_cap: usize = 0, + level: i32 = 0, + last_ordinal: u64 = 0, +}; + +/// A malloc'd T with its default field values; NULL on OOM. +fn create(comptime T: type) ?*T { + const item: ?*T = interop.mallocN(T, 1); + if (item) |p| p.* = .{}; + return item; +} + +/// The bytes of a TEXT or BLOB value. +fn valueBytes(value: *const Value) []const u8 { + return if (value.bytes_len == 0) "" else value.bytes[0..@intCast(value.bytes_len)]; +} + +fn textValue(bytes: []const u8) Value { + return .{ .kind = TEXT, .bytes = bytes.ptr, .bytes_len = bytes.len }; +} + +fn integerValue(integer: i64) Value { + return .{ .kind = INTEGER, .integer = integer }; +} + +fn pluginService(host: [*c]const Host, connection: ?*anyopaque, id: u32, size: u32, out: *?*const anyopaque) i32 { + out.* = null; + if (host == null or host.*.struct_size < @sizeOf(c.zova_plugin_service_host_v1)) + return c.ZOVA_PLUGIN_UNSUPPORTED; + const service_host: *const c.zova_plugin_service_host_v1 = @ptrCast(host); + return service_host.get_service.?(connection, id, 1, size, out); +} + +fn sessionOpen(host: [*c]const Host, connection: ?*anyopaque, session: *Session) i32 { + var data: ?*const anyopaque = null; + var query: ?*const anyopaque = null; + var status = pluginService(host, connection, c.ZOVA_PLUGIN_SERVICE_DATA, @sizeOf(c.zova_plugin_data_service_v1), &data); + if (status == OK) { + status = pluginService(host, connection, c.ZOVA_PLUGIN_SERVICE_QUERY, @sizeOf(c.zova_plugin_query_service_v1), &query); + } + session.* = .{ .connection = connection, .data = @ptrCast(@alignCast(data)), .query = @ptrCast(@alignCast(query)) }; + return status; +} + +fn traceFree(trace: ?*Trace) void { + const t = trace orelse return; + for (0..t.count) |i| c.free(t.rows[i].text); + c.free(t.rows); + c.free(t.keys); + c.free(t.set); + c.free(t); +} + +fn setSlot(set: [*]const i64, cap: usize, key: i64) usize { + const hash = @as(u64, @bitCast(key)) *% 0x9E3779B97F4A7C15; + var slot = @as(usize, @truncate(hash)) & (cap - 1); + while (set[slot] != 0 and set[slot] != key) slot = (slot + 1) & (cap - 1); + return slot; +} + +/// True when key was added, false when it was already present. +fn visit(trace: *Trace, key: i64) error{OutOfMemory}!bool { + if ((trace.key_count + 1) * 2 > trace.set_cap) { + const cap: usize = if (trace.set_cap != 0) trace.set_cap * 2 else 1024; + const set = interop.callocN(i64, cap); + if (set == null) return error.OutOfMemory; + for (0..trace.key_count) |i| set[setSlot(set, cap, trace.keys[i])] = trace.keys[i]; + c.free(trace.set); + trace.set = set; + trace.set_cap = cap; + } + const slot = setSlot(trace.set, trace.set_cap, key); + if (trace.set[slot] == key) return false; + if (trace.key_count == trace.key_cap) { + const cap: usize = if (trace.key_cap != 0) trace.key_cap * 2 else 1024; + const keys = interop.reallocN(i64, trace.keys, cap); + if (keys == null) return error.OutOfMemory; + trace.keys = keys; + trace.key_cap = cap; + } + trace.set[slot] = key; + trace.keys[trace.key_count] = key; + trace.key_count += 1; + return true; +} + +/// NEIGHBORS_KEYED row: ordinal, input_key, edge_key, neighbor_key, edge_type, order. +fn neighborRow(user_data: ?*anyopaque, values: [*c]const Value, count: u64) callconv(.c) i32 { + const trace: *Trace = @ptrCast(@alignCast(user_data)); + if (count != PLUGIN_NEIGHBOR_COLUMNS or values[0].kind != INTEGER or values[0].integer < 0 or + values[3].kind != INTEGER or values[3].integer <= 0) + return ERROR; + trace.last_ordinal = @intCast(values[0].integer); + _ = visit(trace, values[3].integer) catch return OUT_OF_MEMORY; + return OK; +} + +/// Reads every matching neighbor of keys[first..first+count), following the keyed cursor. +fn expand(session: *const Session, trace: *Trace, request: *DataRequest, first_key: usize, key_count: usize) i32 { + var first = first_key; + var count = key_count; + var after = std.mem.zeroes(c.zova_plugin_cursor_v1); + while (count > 0) { + var page = std.mem.zeroes(DataPage); + // The row callback may grow trace.keys; re-read the pointer per page. + request.keys = trace.keys + first; + request.key_count = count; + request.after = after; + trace.last_ordinal = 0; + const status = session.data.*.read.?(session.connection, request, &page); + if (status != OK) return status; + if (page.has_more == 0) break; + if (page.rows == 0 or trace.last_ordinal >= count) return ERROR; + first += @intCast(trace.last_ordinal); + count -= @intCast(trace.last_ordinal); + after = page.next; + } + return OK; +} + +/// Node row: zova_node_key, label, name, qualified_name, file_path, start_line, +/// end_line, properties. +fn nodeRow(user_data: ?*anyopaque, values: [*c]const Value, count: u64) callconv(.c) i32 { + const text_column = [PLUGIN_TEXT_COLUMNS]usize{ 1, 2, 3, 4, 7 }; + const trace: *Trace = @ptrCast(@alignCast(user_data)); + if (count != PLUGIN_NODE_COLUMNS or values[0].kind != INTEGER or values[5].kind != INTEGER or + values[6].kind != INTEGER) + return ERROR; + if (trace.count == trace.cap) { + const cap: usize = if (trace.cap != 0) trace.cap * 2 else 64; + const rows = interop.reallocN(Row, trace.rows, cap); + if (rows == null) return OUT_OF_MEMORY; + trace.rows = rows; + trace.cap = cap; + } + var row = Row{ + .key = values[0].integer, + .start_line = values[5].integer, + .end_line = values[6].integer, + .hop = trace.level, + .text = null, + .off = undefined, + .len = undefined, + }; + var total: u64 = 0; + for (text_column, 0..) |column, i| { + if (values[column].kind != TEXT) return ERROR; + row.off[i] = total; + row.len[i] = values[column].bytes_len; + total += values[column].bytes_len; + } + row.text = interop.mallocN(u8, if (total != 0) @intCast(total) else 1); + if (row.text == null) return OUT_OF_MEMORY; + for (text_column, 0..) |column, i| { + if (row.len[i] != 0) @memcpy(@constCast(row.column(i)), valueBytes(values + column)); + } + trace.rows[trace.count] = row; + trace.count += 1; + return OK; +} + +fn rowsTruncate(trace: *Trace, count: usize) void { + while (trace.count > count) { + trace.count -= 1; + c.free(trace.rows[trace.count].text); + } +} + +/// Loads the cbm_nodes_v1 rows of keys[first..first+count). Graph nodes without a +/// row are skipped. A batch over the query byte budget is retried in halves. +fn hydrate(session: *const Session, trace: *Trace, first_key: usize, key_count: usize) i32 { + const head = PLUGIN_NODE_SELECT ++ "WHERE n.zova_node_key IN ("; + var sql: [head.len + PLUGIN_HYDRATE_BATCH * 2 + 2]u8 = undefined; + var parameters: [PLUGIN_HYDRATE_BATCH]Value = undefined; + var batch: usize = PLUGIN_HYDRATE_BATCH; + var first = first_key; + var count = key_count; + while (count > 0) { + const n = @min(count, batch); + var len: usize = head.len; + @memcpy(sql[0..len], head); + for (0..n) |i| { + if (i > 0) { + sql[len] = ','; + len += 1; + } + sql[len] = '?'; + len += 1; + parameters[i] = integerValue(trace.keys[first + i]); + } + sql[len] = ')'; + len += 1; + const request = QueryRequest{ + .struct_size = @sizeOf(QueryRequest), + .sql = &sql, + .sql_len = len, + .parameters = ¶meters, + .parameter_count = n, + .row_limit = n, + .byte_limit = PLUGIN_PAGE_BYTES, + .row = nodeRow, + .user_data = trace, + }; + const before = trace.count; + const status = session.query.*.query.?(session.connection, &request); + if (status == LIMIT and n > 1) { + rowsTruncate(trace, before); + batch = n / 2; + continue; + } + if (status != OK) return status; + first += n; + count -= n; + } + return OK; +} + +/// Result order: hop, file_path, qualified_name, label, start_line, end_line, key. +fn rowLessThan(_: void, a: Row, b: Row) bool { + if (a.hop != b.hop) return a.hop < b.hop; + for ([_]usize{ ROW_FILE, ROW_QN, ROW_LABEL }) |index| { + const order = std.mem.order(u8, a.column(index), b.column(index)); + if (order != .eq) return order == .lt; + } + if (a.start_line != b.start_line) return a.start_line < b.start_line; + if (a.end_line != b.end_line) return a.end_line < b.end_line; + return a.key < b.key; +} + +/// Splits "A,B" into at most PLUGIN_EDGE_TYPES_MAX non-empty names. An empty +/// list yields 0; a malformed one null. +fn edgeTypes(value: *const Value, out: *[PLUGIN_EDGE_TYPES_MAX]Bytes) ?usize { + const text = valueBytes(value); + if (text.len == 0) return 0; + var count: usize = 0; + var start: usize = 0; + for (0..text.len + 1) |i| { + if (i < text.len and text[i] != ',') continue; + if (i == start or i - start > PLUGIN_NAME_MAX or count == PLUGIN_EDGE_TYPES_MAX) return null; + out[count] = .{ .data = text[start..i].ptr, .len = i - start }; + count += 1; + start = i + 1; + } + return count; +} + +/// No names means every edge type. +fn requestEdgeTypes(request: *DataRequest, edge_types: []const Bytes) void { + if (edge_types.len == 1) { + request.edge_type = edge_types[0]; + } else if (edge_types.len > 1) { + request.ids = edge_types.ptr; + request.id_count = edge_types.len; + } +} + +fn direction(value: *const Value) ?u32 { + const text = valueBytes(value); + if (std.mem.eql(u8, text, "outbound")) return OUTGOING; + if (std.mem.eql(u8, text, "inbound")) return INCOMING; + return null; +} + +/// Walks breadth-first from start_key and leaves the reached nodes in +/// trace.rows, ordered and cut to max_results. +fn traceRun(session: *const Session, trace: *Trace, graph: Bytes, start_key: i64, dir: u32, edge_types: []const Bytes, max_depth: i64, max_results: i64) i32 { + _ = visit(trace, start_key) catch return OUT_OF_MEMORY; + var request = DataRequest{ + .struct_size = @sizeOf(DataRequest), + .operation = c.ZOVA_PLUGIN_GRAPH_NEIGHBORS_KEYED, + .name = graph, + .direction = dir, + .row_limit = PLUGIN_PAGE_ROWS, + .byte_limit = PLUGIN_PAGE_BYTES, + .row = neighborRow, + .user_data = trace, + }; + requestEdgeTypes(&request, edge_types); + + const result_limit: usize = @intCast(max_results); + var level_start: usize = 0; + var level_end: usize = 1; + var hop: i64 = 1; + while (hop <= max_depth and level_start < level_end) : (hop += 1) { + var first = level_start; + while (first < level_end) : (first += PLUGIN_FRONTIER_BATCH) { + const count = @min(level_end - first, PLUGIN_FRONTIER_BATCH); + const status = expand(session, trace, &request, first, count); + if (status != OK) return status; + } + level_start = level_end; + level_end = trace.key_count; + trace.level = @truncate(hop); + const status = hydrate(session, trace, level_start, level_end - level_start); + if (status != OK) return status; + // Later levels sort after this one, so they cannot enter the result. + if (trace.count >= result_limit) break; + } + if (trace.count > 1) std.mem.sortUnstable(Row, trace.rows[0..trace.count], {}, rowLessThan); + rowsTruncate(trace, result_limit); + return OK; +} + +fn traceOpen(host: [*c]const Host, connection: ?*anyopaque, state: ?*anyopaque, arguments: [*c]const Value, argument_count: u64, out_cursor: [*c]?*anyopaque) callconv(.c) i32 { + _ = state; + out_cursor.* = null; + if (argument_count != PLUGIN_TRACE_ARGUMENTS) return INVALID_ARGUMENT; + const graph = Bytes{ .data = arguments[0].bytes, .len = arguments[0].bytes_len }; + const start_key = arguments[1].integer; + const max_depth = arguments[4].integer; + const max_results = arguments[5].integer; + var edge_types: [PLUGIN_EDGE_TYPES_MAX]Bytes = undefined; + const edge_type_count = edgeTypes(arguments + 3, &edge_types) orelse 0; + const dir = direction(arguments + 2) orelse return INVALID_ARGUMENT; + if (graph.len == 0 or graph.len > PLUGIN_NAME_MAX or start_key <= 0 or edge_type_count == 0 or + max_depth < 0 or max_results <= 0 or max_results > PLUGIN_MAX_RESULTS) + return INVALID_ARGUMENT; + var session: Session = undefined; + const status = sessionOpen(host, connection, &session); + if (status != OK) return status; + const trace = create(Trace) orelse return OUT_OF_MEMORY; + out_cursor.* = trace; + return traceRun(&session, trace, graph, start_key, dir, edge_types[0..edge_type_count], max_depth, max_results); +} + +fn traceNext(host: [*c]const Host, connection: ?*anyopaque, cursor: ?*anyopaque, row: c.zova_plugin_row_v1, row_context: ?*anyopaque, out_has_row: [*c]u32) callconv(.c) i32 { + _ = host; + _ = connection; + const text_column = [PLUGIN_TEXT_COLUMNS]usize{ 2, 3, 4, 5, 8 }; + const trace: *Trace = @ptrCast(@alignCast(cursor)); + out_has_row.* = 0; + if (trace.next >= trace.count) return OK; + const item: *const Row = trace.rows + trace.next; + trace.next += 1; + var values: [PLUGIN_TRACE_COLUMNS]Value = undefined; + values[0] = integerValue(item.key); + values[1] = integerValue(item.hop); + for (text_column, 0..) |column, i| values[column] = textValue(item.column(i)); + values[6] = integerValue(item.start_line); + values[7] = integerValue(item.end_line); + out_has_row.* = 1; + return row.?(row_context, &values, PLUGIN_TRACE_COLUMNS); +} + +fn traceClose(cursor: ?*anyopaque) callconv(.c) void { + traceFree(@ptrCast(@alignCast(cursor))); +} + +// ── edges ─────────────────────────────────────────────────────── + +/// One adjacency entry; type is copied because row bytes are borrowed. +const Neighbor = struct { + edge_key: i64, + neighbor_key: i64, + type: [*c]u8, + type_len: u64, +}; + +/// One cbm edge. text holds local_name then properties. A graph edge carries +/// one or more of these in its payload. +const Edge = struct { + neighbor: usize, + text: [*c]u8, + local_name_len: u64, + properties_len: u64, +}; + +const Edges = struct { + neighbors: [*c]Neighbor = null, + neighbor_count: usize = 0, + neighbor_cap: usize = 0, + edges: [*c]Edge = null, + edge_count: usize = 0, + edge_cap: usize = 0, + next: usize = 0, + node_key: i64 = 0, + direction: u32 = 0, + /// First neighbor of the payload batch being read, and the one being decoded. + batch_first: usize = 0, + batch_count: usize = 0, + current: usize = 0, +}; + +fn edgesTruncate(state: *Edges, count: usize) void { + while (state.edge_count > count) { + state.edge_count -= 1; + c.free(state.edges[state.edge_count].text); + } +} + +fn edgesFree(edges: ?*Edges) void { + const state = edges orelse return; + edgesTruncate(state, 0); + for (0..state.neighbor_count) |i| c.free(state.neighbors[i].type); + c.free(state.neighbors); + c.free(state.edges); + c.free(state); +} + +fn edgesNeighborRow(user_data: ?*anyopaque, values: [*c]const Value, count: u64) callconv(.c) i32 { + const state: *Edges = @ptrCast(@alignCast(user_data)); + if (count != PLUGIN_NEIGHBOR_COLUMNS or values[2].kind != INTEGER or values[3].kind != INTEGER or + values[4].kind != TEXT) + return ERROR; + if (state.neighbor_count == state.neighbor_cap) { + const cap: usize = if (state.neighbor_cap != 0) state.neighbor_cap * 2 else 64; + const neighbors = interop.reallocN(Neighbor, state.neighbors, cap); + if (neighbors == null) return OUT_OF_MEMORY; + state.neighbors = neighbors; + state.neighbor_cap = cap; + } + const name = valueBytes(values + 4); + const copy = interop.mallocN(u8, if (name.len != 0) name.len else 1); + if (copy == null) return OUT_OF_MEMORY; + @memcpy(copy[0..name.len], name); + state.neighbors[state.neighbor_count] = .{ + .edge_key = values[2].integer, + .neighbor_key = values[3].integer, + .type = copy, + .type_len = name.len, + }; + state.neighbor_count += 1; + return OK; +} + +fn edgesRecord(record: [*c]const PayloadRecord, context: ?*anyopaque) callconv(.c) c_int { + const state: *Edges = @ptrCast(@alignCast(context)); + if (state.edge_count == state.edge_cap) { + const cap: usize = if (state.edge_cap != 0) state.edge_cap * 2 else 64; + const edges = interop.reallocN(Edge, state.edges, cap); + if (edges == null) return -1; + state.edges = edges; + state.edge_cap = cap; + } + const local_name = record.*.local_name; + const properties = record.*.properties; + const total = local_name.len + properties.len; + const text = interop.mallocN(u8, if (total != 0) total else 1); + if (text == null) return -1; + if (local_name.len != 0) @memcpy(text[0..local_name.len], local_name.data[0..local_name.len]); + if (properties.len != 0) @memcpy(text[local_name.len..total], properties.data[0..properties.len]); + state.edges[state.edge_count] = .{ + .neighbor = state.current, + .text = text, + .local_name_len = local_name.len, + .properties_len = properties.len, + }; + state.edge_count += 1; + return 0; +} + +/// EDGE_PAYLOADS_GET row: ordinal, found, input_key, payload. +fn edgesPayloadRow(user_data: ?*anyopaque, values: [*c]const Value, count: u64) callconv(.c) i32 { + const state: *Edges = @ptrCast(@alignCast(user_data)); + if (count != PLUGIN_PAYLOAD_COLUMNS or values[0].kind != INTEGER or values[0].integer < 0 or + @as(u64, @intCast(values[0].integer)) >= state.batch_count or values[1].kind != INTEGER or + values[1].integer != 1 or values[3].kind != BLOB) + return ERROR; + state.current = state.batch_first + @as(usize, @intCast(values[0].integer)); + if (values[2].integer != state.neighbors[state.current].edge_key) return ERROR; + const rc = c.cbm_zova_edge_payload_visit(values[3].bytes, @intCast(values[3].bytes_len), edgesRecord, state, null); + return if (rc == 0) OK else ERROR; +} + +/// Decodes the payloads of every collected neighbor edge. A batch over the byte +/// budget is retried in halves. +fn edgesPayloads(session: *const Session, state: *Edges, graph: Bytes) i32 { + var keys: [PLUGIN_PAYLOAD_BATCH]i64 = undefined; + var batch: usize = PLUGIN_PAYLOAD_BATCH; + var first: usize = 0; + while (first < state.neighbor_count) { + const n = @min(state.neighbor_count - first, batch); + for (0..n) |i| keys[i] = state.neighbors[first + i].edge_key; + const request = DataRequest{ + .struct_size = @sizeOf(DataRequest), + .operation = c.ZOVA_PLUGIN_GRAPH_EDGE_PAYLOADS_GET, + .name = graph, + .keys = &keys, + .key_count = n, + .row_limit = n, + .byte_limit = PLUGIN_PAGE_BYTES, + .row = edgesPayloadRow, + .user_data = state, + }; + var page = std.mem.zeroes(DataPage); + const before = state.edge_count; + state.batch_first = first; + state.batch_count = n; + const status = session.data.*.read.?(session.connection, &request, &page); + if (status == LIMIT and n > 1) { + edgesTruncate(state, before); + batch = n / 2; + continue; + } + if (status != OK) return status; + if (page.rows != n) return ERROR; + first += n; + } + return OK; +} + +/// Collects the adjacency of node_key into state.neighbors. +fn edgesNeighbors(session: *const Session, state: *Edges, graph: Bytes, node_key: i64, dir: u32, edge_types: []const Bytes) i32 { + state.node_key = node_key; + state.direction = dir; + var request = DataRequest{ + .struct_size = @sizeOf(DataRequest), + .operation = c.ZOVA_PLUGIN_GRAPH_NEIGHBORS_KEYED, + .name = graph, + .keys = &state.node_key, + .key_count = 1, + .direction = dir, + .row_limit = PLUGIN_PAGE_ROWS, + .byte_limit = PLUGIN_PAGE_BYTES, + .row = edgesNeighborRow, + .user_data = state, + }; + requestEdgeTypes(&request, edge_types); + while (true) { + var page = std.mem.zeroes(DataPage); + const status = session.data.*.read.?(session.connection, &request, &page); + if (status != OK) return status; + if (page.has_more == 0) return OK; + if (page.rows == 0) return ERROR; + request.after = page.next; + } +} + +fn edgesOpen(host: [*c]const Host, connection: ?*anyopaque, unused: ?*anyopaque, arguments: [*c]const Value, argument_count: u64, out_cursor: [*c]?*anyopaque) callconv(.c) i32 { + _ = unused; + out_cursor.* = null; + if (argument_count != PLUGIN_EDGES_ARGUMENTS) return INVALID_ARGUMENT; + const graph = Bytes{ .data = arguments[0].bytes, .len = arguments[0].bytes_len }; + var edge_types: [PLUGIN_EDGE_TYPES_MAX]Bytes = undefined; + const edge_type_count = edgeTypes(arguments + 3, &edge_types) orelse return INVALID_ARGUMENT; + const dir = direction(arguments + 2) orelse return INVALID_ARGUMENT; + if (graph.len == 0 or graph.len > PLUGIN_NAME_MAX or arguments[1].integer <= 0) + return INVALID_ARGUMENT; + var session: Session = undefined; + var status = sessionOpen(host, connection, &session); + if (status != OK) return status; + const state = create(Edges) orelse return OUT_OF_MEMORY; + out_cursor.* = state; + status = edgesNeighbors(&session, state, graph, arguments[1].integer, dir, edge_types[0..edge_type_count]); + return if (status == OK) edgesPayloads(&session, state, graph) else status; +} + +fn edgesNext(host: [*c]const Host, connection: ?*anyopaque, cursor: ?*anyopaque, row: c.zova_plugin_row_v1, row_context: ?*anyopaque, out_has_row: [*c]u32) callconv(.c) i32 { + _ = host; + _ = connection; + const state: *Edges = @ptrCast(@alignCast(cursor)); + out_has_row.* = 0; + if (state.next >= state.edge_count) return OK; + const edge: *const Edge = state.edges + state.next; + state.next += 1; + const neighbor: *const Neighbor = state.neighbors + edge.neighbor; + const outgoing = state.direction == OUTGOING; + const local_name_len: usize = @intCast(edge.local_name_len); + const values = [PLUGIN_EDGES_COLUMNS]Value{ + integerValue(neighbor.edge_key), + integerValue(if (outgoing) state.node_key else neighbor.neighbor_key), + integerValue(if (outgoing) neighbor.neighbor_key else state.node_key), + .{ .kind = TEXT, .bytes = neighbor.type, .bytes_len = neighbor.type_len }, + .{ .kind = TEXT, .bytes = edge.text, .bytes_len = edge.local_name_len }, + .{ .kind = TEXT, .bytes = edge.text + local_name_len, .bytes_len = edge.properties_len }, + }; + out_has_row.* = 1; + return row.?(row_context, &values, PLUGIN_EDGES_COLUMNS); +} + +fn edgesClose(cursor: ?*anyopaque) callconv(.c) void { + edgesFree(@ptrCast(@alignCast(cursor))); +} + +// ── cypher ────────────────────────────────────────────────────── + +/// Store backend for one workspace: answers the reads the Cypher engine makes +/// from the plugin services. Node ids are Zova node keys and edge ids Zova edge +/// keys, so id(n) and id(r) differ from the host store's hashed ids. +const Store = struct { + session: Session, + graph: Bytes = std.mem.zeroes(Bytes), + graph_name: [c.CBM_ZOVA_WORKSPACE_ID_MAX + 32]u8 = undefined, + workspace_key: i64 = 0, + project: [*c]u8 = null, +}; + +/// A malloc'd NUL-terminated copy; NULL on OOM. +fn dup(bytes: []const u8) [*c]u8 { + const copy = interop.mallocN(u8, bytes.len + 1); + if (copy == null) return null; + @memcpy(copy[0..bytes.len], bytes); + copy[bytes.len] = 0; + return copy; +} + +fn nodeClear(node: *c.cbm_node_t) void { + c.free(@constCast(node.project)); + c.free(@constCast(node.label)); + c.free(@constCast(node.name)); + c.free(@constCast(node.qualified_name)); + c.free(@constCast(node.file_path)); + c.free(@constCast(node.properties_json)); + node.* = std.mem.zeroes(c.cbm_node_t); +} + +fn nodeFromRow(store: *const Store, row: *const Row, out: *c.cbm_node_t) bool { + out.* = .{ + .id = row.key, + .project = dup(std.mem.span(store.project)), + .label = dup(row.column(ROW_LABEL)), + .name = dup(row.column(ROW_NAME)), + .qualified_name = dup(row.column(ROW_QN)), + .file_path = dup(row.column(ROW_FILE)), + .start_line = @truncate(row.start_line), + .end_line = @truncate(row.end_line), + .properties_json = dup(row.column(ROW_PROPERTIES)), + }; + if (out.project != null and out.label != null and out.name != null and + out.qualified_name != null and out.file_path != null and out.properties_json != null) + return true; + nodeClear(out); + return false; +} + +/// Workspace nodes ordered by name, qualified_name, key, optionally of one label. +/// Read in keyset pages; a page over the byte budget is retried at half size. +fn storeSearch(ctx: ?*anyopaque, params: [*c]const c.cbm_search_params_t, out: [*c]c.cbm_search_output_t) callconv(.c) c_int { + const sql = PLUGIN_NODE_SELECT ++ + "WHERE n.workspace_key=?1 AND (?2='' OR n.label=?2) AND " ++ + "(?3=0 OR (n.name,n.qualified_name,n.zova_node_key)>(?4,?5,?6)) " ++ + "ORDER BY n.name,n.qualified_name,n.zova_node_key LIMIT ?7"; + const store: *const Store = @ptrCast(@alignCast(ctx)); + out.* = std.mem.zeroes(c.cbm_search_output_t); + // The engine only scans by label; refuse filters this backend does not apply. + if (params.*.name_pattern != null or params.*.qn_pattern != null or + params.*.file_pattern != null or params.*.relationship != null or + params.*.exclude_labels != null or params.*.exclude_entry_points or + params.*.min_degree >= 0 or params.*.max_degree >= 0 or params.*.offset != 0) + return STORE_ERR; + const limit: usize = if (params.*.limit > 0) @intCast(params.*.limit) else 10; + const label: []const u8 = if (params.*.label != null) std.mem.span(params.*.label) else ""; + const rows = create(Trace) orelse return STORE_ERR; + defer traceFree(rows); + var page: usize = PLUGIN_SCAN_PAGE; + var rc = STORE_OK; + while (rows.count < limit) { + const n = @min(limit - rows.count, page); + const last: ?*const Row = if (rows.count != 0) rows.rows + (rows.count - 1) else null; + const parameters = [_]Value{ + integerValue(store.workspace_key), + textValue(label), + integerValue(@intFromBool(last != null)), + textValue(if (last) |row| row.column(ROW_NAME) else ""), + textValue(if (last) |row| row.column(ROW_QN) else ""), + integerValue(if (last) |row| row.key else 0), + integerValue(@intCast(n)), + }; + const request = QueryRequest{ + .struct_size = @sizeOf(QueryRequest), + .sql = sql, + .sql_len = sql.len, + .parameters = ¶meters, + .parameter_count = parameters.len, + .row_limit = n, + .byte_limit = PLUGIN_PAGE_BYTES, + .row = nodeRow, + .user_data = rows, + }; + const before = rows.count; + const status = store.session.query.*.query.?(store.session.connection, &request); + if (status == LIMIT and n > 1) { + rowsTruncate(rows, before); + page = n / 2; + continue; + } + if (status != OK) { + rc = STORE_ERR; + break; + } + if (rows.count - before < n) break; + } + var results: [*c]c.cbm_search_result_t = null; + if (rc == STORE_OK and rows.count != 0) { + results = interop.callocN(c.cbm_search_result_t, rows.count); + if (results == null) rc = STORE_ERR; + } + var i: usize = 0; + while (rc == STORE_OK and i < rows.count) : (i += 1) { + const result: *c.cbm_search_result_t = results + i; + if (!nodeFromRow(store, rows.rows + i, &result.node)) { + rc = STORE_ERR; + break; + } + out.*.count = @intCast(i + 1); + } + out.*.results = results; + out.*.total = out.*.count; + if (rc != STORE_OK) c.cbm_store_search_free(out); + return rc; +} + +fn storeFindNodeById(ctx: ?*anyopaque, id: i64, out: [*c]c.cbm_node_t) callconv(.c) c_int { + const store: *const Store = @ptrCast(@alignCast(ctx)); + out.* = std.mem.zeroes(c.cbm_node_t); + if (id <= 0) return STORE_NOT_FOUND; + const rows = create(Trace) orelse return STORE_ERR; + defer traceFree(rows); + const added = visit(rows, id) catch return STORE_ERR; + if (!added or hydrate(&store.session, rows, 0, 1) != OK) return STORE_ERR; + if (rows.count == 0) return STORE_NOT_FOUND; + return if (nodeFromRow(store, rows.rows, out)) STORE_OK else STORE_ERR; +} + +/// NULL means outbound, like the host store. +fn storeDirection(text: [*c]const u8) ?u32 { + if (text == null) return OUTGOING; + const name = std.mem.span(text); + if (std.mem.eql(u8, name, "outbound")) return OUTGOING; + if (std.mem.eql(u8, name, "inbound")) return INCOMING; + return null; +} + +fn storeFindEdges(ctx: ?*anyopaque, node_id: i64, direction_name: [*c]const u8, type_name: [*c]const u8, out: [*c][*c]c.cbm_edge_t, count: [*c]c_int) callconv(.c) c_int { + const store: *const Store = @ptrCast(@alignCast(ctx)); + out.* = null; + count.* = 0; + if (node_id <= 0) return STORE_ERR; + const dir = storeDirection(direction_name) orelse return STORE_ERR; + const edge_type = [1]Bytes{.{ .data = type_name, .len = if (type_name != null) c.strlen(type_name) else 0 }}; + if (edge_type[0].len > PLUGIN_NAME_MAX) return STORE_ERR; + const state = create(Edges) orelse return STORE_ERR; + defer edgesFree(state); + const edge_types: []const Bytes = if (edge_type[0].len != 0) &edge_type else &.{}; + if (edgesNeighbors(&store.session, state, store.graph, node_id, dir, edge_types) != OK or + edgesPayloads(&store.session, state, store.graph) != OK) + return STORE_ERR; + if (state.edge_count == 0) return STORE_OK; + const edges = interop.callocN(c.cbm_edge_t, state.edge_count); + if (edges == null) return STORE_ERR; + const outgoing = dir == OUTGOING; + for (0..state.edge_count) |i| { + const edge: *const Edge = state.edges + i; + const neighbor: *const Neighbor = state.neighbors + edge.neighbor; + const local_name_len: usize = @intCast(edge.local_name_len); + edges[i] = .{ + .id = neighbor.edge_key, + .project = dup(std.mem.span(store.project)), + .source_id = if (outgoing) node_id else neighbor.neighbor_key, + .target_id = if (outgoing) neighbor.neighbor_key else node_id, + .type = dup(neighbor.type[0..@intCast(neighbor.type_len)]), + .properties_json = dup((edge.text + local_name_len)[0..@intCast(edge.properties_len)]), + }; + if (edges[i].project == null or edges[i].type == null or edges[i].properties_json == null) { + c.cbm_store_free_edges(edges, @intCast(i + 1)); + return STORE_ERR; + } + } + out.* = edges; + count.* = @intCast(state.edge_count); + return STORE_OK; +} + +/// CALLS edges in and out, like the host store. +fn storeNodeDegree(ctx: ?*anyopaque, node_id: i64, in_deg: [*c]c_int, out_deg: [*c]c_int) callconv(.c) c_int { + const calls = [1]Bytes{.{ .data = "CALLS", .len = 5 }}; + const store: *const Store = @ptrCast(@alignCast(ctx)); + const degrees = [2][*c]c_int{ out_deg, in_deg }; + const directions = [2]u32{ OUTGOING, INCOMING }; + if (node_id <= 0) return STORE_ERR; + for (degrees, directions) |degree, dir| { + const state = create(Edges) orelse return STORE_ERR; + defer edgesFree(state); + const status = edgesNeighbors(&store.session, state, store.graph, node_id, dir, &calls); + degree.* = @intCast(state.neighbor_count); + if (status != OK) return STORE_ERR; + } + return STORE_OK; +} + +fn storeBfs(ctx: ?*anyopaque, start_id: i64, direction_name: [*c]const u8, edge_types_in: [*c][*c]const u8, edge_type_count_in: c_int, max_depth: c_int, max_results: c_int, out: [*c]c.cbm_traverse_result_t) callconv(.c) c_int { + const default_types = [1][*c]const u8{"CALLS"}; + const store: *const Store = @ptrCast(@alignCast(ctx)); + out.* = std.mem.zeroes(c.cbm_traverse_result_t); + const result: *c.cbm_traverse_result_t = out; + // The host store walks one direction per call too. + const dir = storeDirection(direction_name) orelse return STORE_NOT_FOUND; + var edge_types: [*c]const [*c]const u8 = edge_types_in; + var edge_type_count = edge_type_count_in; + if (edge_type_count <= 0) { + edge_types = &default_types; + edge_type_count = 1; + } + if (edge_type_count > PLUGIN_EDGE_TYPES_MAX or max_depth < 0 or max_results <= 0 or + max_results > PLUGIN_MAX_RESULTS) + return STORE_ERR; + var types: [PLUGIN_EDGE_TYPES_MAX]Bytes = undefined; + const type_count: usize = @intCast(edge_type_count); + for (0..type_count) |i| { + types[i] = .{ .data = edge_types[i], .len = c.strlen(edge_types[i]) }; + if (types[i].len == 0 or types[i].len > PLUGIN_NAME_MAX) return STORE_ERR; + } + var rc = storeFindNodeById(ctx, start_id, &result.root); + if (rc != STORE_OK) return rc; + rc = STORE_ERR; + const trace = create(Trace); + defer traceFree(trace); + if (trace) |t| { + if (traceRun(&store.session, t, store.graph, start_id, dir, types[0..type_count], max_depth, max_results) == OK) { + if (t.count != 0) result.visited = interop.callocN(c.cbm_node_hop_t, t.count); + rc = if (t.count != 0 and result.visited == null) STORE_ERR else STORE_OK; + var i: usize = 0; + while (rc == STORE_OK and i < t.count) : (i += 1) { + const visited: *c.cbm_node_hop_t = result.visited + i; + if (!nodeFromRow(store, t.rows + i, &visited.node)) { + rc = STORE_ERR; + break; + } + visited.hop = t.rows[i].hop; + result.visited_count = @intCast(i + 1); + } + } + } + if (rc != STORE_OK) c.cbm_store_traverse_free(out); + return rc; +} + +const plugin_store_backend = c.cbm_store_backend_t{ + .search = storeSearch, + .find_node_by_id = storeFindNodeById, + .find_edges = storeFindEdges, + .node_degree = storeNodeDegree, + .bfs = storeBfs, +}; + +/// Workspace row: workspace_key, project. +fn workspaceRow(user_data: ?*anyopaque, values: [*c]const Value, count: u64) callconv(.c) i32 { + const store: *Store = @ptrCast(@alignCast(user_data)); + if (count != 2 or values[0].kind != INTEGER or values[1].kind != TEXT) return ERROR; + store.workspace_key = values[0].integer; + store.project = dup(valueBytes(values + 1)); + return if (store.project != null) OK else OUT_OF_MEMORY; +} + +/// Result rows as (kind, cells) pairs, in emission order. +const Cypher = struct { + kinds: [*c][*c]const u8 = null, + cells: [*c][*c]u8 = null, + count: usize = 0, + next: usize = 0, +}; + +fn cypherFree(cypher: ?*Cypher) void { + const cursor = cypher orelse return; + for (0..cursor.count) |i| c.free(cursor.cells[i]); + c.free(@ptrCast(cursor.kinds)); + c.free(@ptrCast(cursor.cells)); + c.free(cursor); +} + +/// Takes ownership of cells; NULL cells reports an allocation failure. +fn cypherPush(cursor: *Cypher, kind: [*:0]const u8, cells: [*c]u8) bool { + if (cells == null) return false; + cursor.kinds[cursor.count] = kind; + cursor.cells[cursor.count] = cells; + cursor.count += 1; + return true; +} + +fn jsonArray(values: [*c]const [*c]const u8, count: c_int) [*c]u8 { + const doc = c.yyjson_mut_doc_new(null); + if (doc == null) return null; + const array = c.yyjson_mut_arr(doc); + c.yyjson_mut_doc_set_root(doc, array); + for (0..@intCast(@max(count, 0))) |i| { + if (values[i] != null) { + _ = c.yyjson_mut_arr_add_str(doc, array, values[i]); + } else { + _ = c.yyjson_mut_arr_add_null(doc, array); + } + } + const json = c.yyjson_mut_write(doc, 0, null); + c.yyjson_mut_doc_free(doc); + return json; +} + +fn cypherRun(session: *const Session, arguments: [*c]const Value, cursor: *Cypher) i32 { + const workspace_sql = + "SELECT r.workspace_key,p.project FROM cbm_workspace_registry r " ++ + "JOIN cbm_projects_v1 p ON p.workspace_key=r.workspace_key WHERE r.workspace_id=?1"; + var store = Store{ .session = session.* }; + const workspace_id = dup(valueBytes(arguments)); + const query = dup(valueBytes(arguments + 1)); + var handle: ?*c.cbm_store_t = null; + var result = std.mem.zeroes(c.cbm_cypher_result_t); + defer { + c.cbm_cypher_result_free(&result); + c.cbm_store_close(handle); + c.free(store.project); + c.free(workspace_id); + c.free(query); + } + // Up to three rows besides the result rows: columns, warning, error. + cursor.kinds = interop.callocN([*c]const u8, 3); + cursor.cells = interop.callocN([*c]u8, 3); + if (workspace_id == null or query == null or cursor.kinds == null or cursor.cells == null) + return OUT_OF_MEMORY; + if (c.strlen(workspace_id) != arguments[0].bytes_len or c.strlen(query) != arguments[1].bytes_len or + c.cbm_zova_workspace_graph_name(workspace_id, &store.graph_name, store.graph_name.len) != 0) + return INVALID_ARGUMENT; + store.graph = .{ .data = &store.graph_name, .len = c.strlen(&store.graph_name) }; + const request = QueryRequest{ + .struct_size = @sizeOf(QueryRequest), + .sql = workspace_sql, + .sql_len = workspace_sql.len, + .parameters = arguments, + .parameter_count = 1, + .row_limit = 1, + .byte_limit = PLUGIN_PAGE_BYTES, + .row = workspaceRow, + .user_data = &store, + }; + const status = session.query.*.query.?(session.connection, &request); + if (status != OK) return status; + if (store.project == null) + return if (cypherPush(cursor, "error", dup("workspace not found"))) OK else OUT_OF_MEMORY; + handle = c.cbm_store_open_backend(&plugin_store_backend, &store); + if (handle == null) return OUT_OF_MEMORY; + if (c.cbm_cypher_execute(handle, query, store.project, @intCast(arguments[2].integer), &result) != 0) { + const message: []const u8 = if (result.@"error" != null) std.mem.span(result.@"error") else "query failed"; + return if (cypherPush(cursor, "error", dup(message))) OK else OUT_OF_MEMORY; + } + const row_count: usize = @intCast(result.row_count); + const capacity = row_count + 3; + const kinds = interop.reallocN([*c]const u8, cursor.kinds, capacity); + if (kinds == null) return OUT_OF_MEMORY; + cursor.kinds = kinds; + const cells = interop.reallocN([*c]u8, cursor.cells, capacity); + if (cells == null) return OUT_OF_MEMORY; + cursor.cells = cells; + if (!cypherPush(cursor, "columns", jsonArray(result.columns, result.col_count))) + return OUT_OF_MEMORY; + for (0..row_count) |i| { + if (!cypherPush(cursor, "row", jsonArray(result.rows[i], result.col_count))) + return OUT_OF_MEMORY; + } + if (result.warning != null and !cypherPush(cursor, "warning", dup(std.mem.span(result.warning)))) + return OUT_OF_MEMORY; + return OK; +} + +fn cypherOpen(host: [*c]const Host, connection: ?*anyopaque, unused: ?*anyopaque, arguments: [*c]const Value, argument_count: u64, out_cursor: [*c]?*anyopaque) callconv(.c) i32 { + _ = unused; + out_cursor.* = null; + if (argument_count != PLUGIN_CYPHER_ARGUMENTS or arguments[1].bytes_len == 0 or + arguments[2].integer < 0 or arguments[2].integer > std.math.maxInt(i32)) + return INVALID_ARGUMENT; + var session: Session = undefined; + const status = sessionOpen(host, connection, &session); + if (status != OK) return status; + const cursor = create(Cypher) orelse return OUT_OF_MEMORY; + out_cursor.* = cursor; + return cypherRun(&session, arguments, cursor); +} + +fn cypherNext(host: [*c]const Host, connection: ?*anyopaque, raw: ?*anyopaque, row: c.zova_plugin_row_v1, row_context: ?*anyopaque, out_has_row: [*c]u32) callconv(.c) i32 { + _ = host; + _ = connection; + const cursor: *Cypher = @ptrCast(@alignCast(raw)); + out_has_row.* = 0; + if (cursor.next >= cursor.count) return OK; + const values = [PLUGIN_CYPHER_COLUMNS]Value{ + textValue(std.mem.span(cursor.kinds[cursor.next])), + textValue(std.mem.span(cursor.cells[cursor.next])), + }; + cursor.next += 1; + out_has_row.* = 1; + return row.?(row_context, &values, PLUGIN_CYPHER_COLUMNS); +} + +fn cypherClose(cursor: ?*anyopaque) callconv(.c) void { + cypherFree(@ptrCast(@alignCast(cursor))); +} + +// ── registration ──────────────────────────────────────────────── + +fn tableColumn(comptime name: []const u8, kind: u32) Column { + return .{ .name = .{ .data = name.ptr, .len = name.len }, .kind = kind, .nullable = 0 }; +} + +const trace_arguments = [PLUGIN_TRACE_ARGUMENTS]Column{ + tableColumn("graph", TEXT), + tableColumn("start_key", INTEGER), + tableColumn("direction", TEXT), + tableColumn("edge_types", TEXT), + tableColumn("max_depth", INTEGER), + tableColumn("max_results", INTEGER), +}; +const trace_columns = [PLUGIN_TRACE_COLUMNS]Column{ + tableColumn("node_key", INTEGER), + tableColumn("hop", INTEGER), + tableColumn("label", TEXT), + tableColumn("name", TEXT), + tableColumn("qualified_name", TEXT), + tableColumn("file_path", TEXT), + tableColumn("start_line", INTEGER), + tableColumn("end_line", INTEGER), + tableColumn("properties", TEXT), +}; + +const edges_arguments = [PLUGIN_EDGES_ARGUMENTS]Column{ + tableColumn("graph", TEXT), + tableColumn("node_key", INTEGER), + tableColumn("direction", TEXT), + tableColumn("edge_types", TEXT), +}; +const edges_columns = [PLUGIN_EDGES_COLUMNS]Column{ + tableColumn("edge_key", INTEGER), + tableColumn("source_key", INTEGER), + tableColumn("target_key", INTEGER), + tableColumn("edge_type", TEXT), + tableColumn("local_name", TEXT), + tableColumn("properties", TEXT), +}; + +const cypher_arguments = [PLUGIN_CYPHER_ARGUMENTS]Column{ + tableColumn("workspace_id", TEXT), + tableColumn("query", TEXT), + tableColumn("max_rows", INTEGER), +}; +const cypher_columns = [PLUGIN_CYPHER_COLUMNS]Column{ + tableColumn("kind", TEXT), + tableColumn("cells", TEXT), +}; + +/// An exact, ordered table operation. +fn tableOperation( + comptime name: []const u8, + arguments: []const Column, + columns: []const Column, + open: c.zova_plugin_operation_open_v1, + next: c.zova_plugin_operation_next_v1, + close: c.zova_plugin_operation_close_v1, +) Operation { + return .{ + .struct_size = @sizeOf(Operation), + .kind = c.ZOVA_PLUGIN_OPERATION_TABLE, + .flags = c.ZOVA_PLUGIN_OPERATION_EXACT | c.ZOVA_PLUGIN_OPERATION_ORDERED, + .name = .{ .data = name.ptr, .len = name.len }, + .arguments = arguments.ptr, + .argument_count = @intCast(arguments.len), + .column_count = @intCast(columns.len), + .columns = columns.ptr, + .open = open, + .next = next, + .close = close, + }; +} + +fn registerSql(host: [*c]const Host, connection: ?*anyopaque) callconv(.c) i32 { + var raw: ?*const anyopaque = null; + var status = pluginService(host, connection, c.ZOVA_PLUGIN_SERVICE_OPERATIONS, @sizeOf(c.zova_plugin_operation_service_v1), &raw); + if (status != OK) return status; + const operations: *const c.zova_plugin_operation_service_v1 = @ptrCast(@alignCast(raw)); + + const trace = tableOperation("trace_path", &trace_arguments, &trace_columns, traceOpen, traceNext, traceClose); + status = operations.register_operation.?(connection, &trace); + if (status != OK) return status; + + const edges = tableOperation("edges", &edges_arguments, &edges_columns, edgesOpen, edgesNext, edgesClose); + status = operations.register_operation.?(connection, &edges); + if (status != OK) return status; + + const cypher = tableOperation("cypher", &cypher_arguments, &cypher_columns, cypherOpen, cypherNext, cypherClose); + return operations.register_operation.?(connection, &cypher); +} + +/// The plugin owns no tables. data_version names the cbm tables and graph layout +/// it reads (cbm_nodes_v1, cbm_files_v1, cbm_graph_); a newer plugin +/// keeps opening older installs by listing them in accepted_data_versions. +const plugin_descriptor = c.zova_plugin_compatibility_descriptor_v1{ + .base = .{ + .base = .{ + .struct_size = @sizeOf(c.zova_plugin_compatibility_descriptor_v1), + .abi_version = c.ZOVA_PLUGIN_ABI_V1, + .flags = c.ZOVA_PLUGIN_REQUIRES_QUERY_V1 | c.ZOVA_PLUGIN_REQUIRES_DATA_V1 | + c.ZOVA_PLUGIN_REQUIRES_OPERATIONS_V1 | c.ZOVA_PLUGIN_HAS_DATA_COMPATIBILITY_V1, + .name = "cbm", + .version = "0.1.0", + .storage_prefix = "_zova_ext_cbm_", + .zova_abi_min = "1.3.0", + .capabilities = "", + .register_sql = registerSql, + }, + }, + .data_version = "1.0.0", + .accepted_data_versions = "1.0.0", +}; + +/// NULL when the build has no Zova. +pub export fn cbm_zova_plugin_descriptor() ?*const c.struct_zova_plugin_descriptor_v1 { + return if (with_zova) &plugin_descriptor.base.base else null; +} From 3fe5781a890b9a347f98d90c17201cbc2721b483 Mon Sep 17 00:00:00 2001 From: ata-sesli Date: Tue, 10 Nov 2026 21:23:00 +0300 Subject: [PATCH 5/9] refactor(zova): port cbm_zova_publish_model from C to Zig cbm_zova_publish_model.c (1,472 lines) becomes cbm_zova_publish_model.zig (1,309 lines). The header is unchanged and stays the ABI; cbm_zig.zig checks every export against it. Wiring: - Makefile.cbm: the C file leaves ZOVA_SRCS; the Zig file and pipeline/worker_pool.h join CBM_ZIG_SRCS. - build.zig: the C file is removed from the native sources. - cbm_zig_c.h: pipeline/worker_pool.h is now translated, because the node build runs through cbm_parallel_for. - tests/test_github_workflows.sh: the Makefile check names the Zig file. Form differences from the C file: - Internal steps return an error union; the exports map it to -1. - The single-field wrapper structs around edges, hashes and vectors are gone; the model holds the public row types directly. - Sorting still goes through qsort with the same comparators, so the order of equal rows is unchanged. - The dead check `unique_topology <= 0` is dropped. - cbm_zova_publish_model_reuse_vector_digests measures the hex digests inside their 65-byte arrays instead of with an unbounded strlen. - The node chunk count is computed in usize; the C `count + 1023` could overflow int near INT_MAX. No behavior differences intended. Verified with the pinned Zova: zova 39, zova_operations 59, zova_incremental_native 1, zova_plugin 8, zova_bridge 4, zova_c_sql_functions 3, mcp 176, cli 132 (same as main). scripts/security-audit.sh, scripts/check-build-sources.sh, tests/test_github_workflows.sh and zig fmt --check pass. Signed-off-by: ata-sesli --- Makefile.cbm | 5 +- build.zig | 1 - src/cbm_zig.zig | 1 + src/cbm_zig_c.h | 1 + src/zova/cbm_zova_publish_model.c | 1472 --------------------------- src/zova/cbm_zova_publish_model.zig | 1309 ++++++++++++++++++++++++ tests/test_github_workflows.sh | 2 +- 7 files changed, 1315 insertions(+), 1476 deletions(-) delete mode 100644 src/zova/cbm_zova_publish_model.c create mode 100644 src/zova/cbm_zova_publish_model.zig diff --git a/Makefile.cbm b/Makefile.cbm index 2ed1b664..7717a8c7 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -153,7 +153,6 @@ PREPROCESSOR_SRC = $(CBM_DIR)/preprocessor.cpp # CBM_WITH_ZOVA=1 is set for the build. ZOVA_SRCS = \ src/zova/cbm_zova.c \ - src/zova/cbm_zova_publish_model.c \ src/zova/cbm_zova_operations.c \ src/zova/cbm_zova_repository.c @@ -462,6 +461,7 @@ CBM_ZIG_SRCS = $(CBM_ZIG_SRC) src/cbm_zig_c.h src/cli_zig/cbm_cli_zig.zig src/cl src/cli/tool_args.zig \ src/cli/hook_augment.zig src/cli/progress_sink.zig src/cli/progress_sink.h \ src/cli/cli.h src/mcp/mcp.h src/pipeline/pipeline.h \ + src/pipeline/worker_pool.h \ src/mcp/mcp.zig src/mcp/server.zig src/mcp/code_tools.zig \ src/mcp/graph_tools.zig src/mcp/index_tools.zig src/mcp/project_tools.zig \ src/mcp/search_tools.zig src/mcp/index_supervisor.zig src/mcp/index_supervisor.h \ @@ -486,7 +486,8 @@ CBM_ZIG_SRCS = $(CBM_ZIG_SRC) src/cbm_zig_c.h src/cli_zig/cbm_cli_zig.zig src/cl src/watcher/watcher.zig src/watcher/watcher.h \ src/zova/cbm_zova_bridge_stub.zig src/zova/cbm_zova_coordination.zig \ src/zova/cbm_zova_delta.zig src/zova/cbm_zova_edge_payload.zig \ - src/zova/cbm_zova_plugin.zig src/zova/cbm_zova_route.zig \ + src/zova/cbm_zova_plugin.zig src/zova/cbm_zova_publish_model.zig \ + src/zova/cbm_zova_route.zig \ src/zova/cbm_zova_v5_snapshot.zig \ src/zova/cbm_zova_writer_gate.zig \ $(wildcard src/zova/*.h) diff --git a/build.zig b/build.zig index 65094f5e..43353fd0 100644 --- a/build.zig +++ b/build.zig @@ -537,7 +537,6 @@ const native_sources = [_][]const u8{ "src/foundation/hash_table.c", "src/foundation/c_shim.c", "src/zova/cbm_zova.c", - "src/zova/cbm_zova_publish_model.c", "src/zova/cbm_zova_operations.c", "src/zova/cbm_zova_repository.c", "src/pipeline/fqn.c", diff --git a/src/cbm_zig.zig b/src/cbm_zig.zig index ae6c47fd..64bdfa9b 100644 --- a/src/cbm_zig.zig +++ b/src/cbm_zig.zig @@ -69,6 +69,7 @@ comptime { checkExports(@import("zova/cbm_zova_delta.zig")); checkExports(@import("zova/cbm_zova_edge_payload.zig")); checkExports(@import("zova/cbm_zova_plugin.zig")); + checkExports(@import("zova/cbm_zova_publish_model.zig")); checkExports(@import("zova/cbm_zova_route.zig")); checkExports(@import("zova/cbm_zova_v5_snapshot.zig")); checkExports(@import("zova/cbm_zova_writer_gate.zig")); diff --git a/src/cbm_zig_c.h b/src/cbm_zig_c.h index 2e7d8d0f..b4309261 100644 --- a/src/cbm_zig_c.h +++ b/src/cbm_zig_c.h @@ -92,6 +92,7 @@ #include "foundation/yaml.h" #include "mcp/mcp.h" #include "pipeline/pipeline.h" +#include "pipeline/worker_pool.h" #include "semantic/semantic.h" #include "store/store.h" #include "tree_sitter/api.h" diff --git a/src/zova/cbm_zova_publish_model.c b/src/zova/cbm_zova_publish_model.c deleted file mode 100644 index 8962cc3b..00000000 --- a/src/zova/cbm_zova_publish_model.c +++ /dev/null @@ -1,1472 +0,0 @@ -#include "cbm_zova_publish_model.h" -#include "cbm_zova_edge_payload.h" -#include "cbm_zova_repository.h" - -#include "foundation/sha256.h" -#include "foundation/sha256_backend.h" -#include "foundation/compat.h" -#include "pipeline/worker_pool.h" - -#include -#include -#include -#include - -enum { - MODEL_ID_CAP = CBM_ZOVA_WORKSPACE_ID_MAX + 80, - MODEL_PATH_CAP = 4096, - MODEL_DISCRIMINATOR_CAP = 512, -}; - -typedef struct { - cbm_zova_publish_node_t value; - char *fts_name; -} model_node_t; - -typedef struct { - cbm_zova_publish_edge_t value; -} model_edge_t; - -typedef struct { - int64_t dump_id; - const char *stable_id; - uint64_t ordinal; -} model_id_map_t; - -typedef struct { - const cbm_zova_file_hash_input_t *source; -} model_hash_t; - -typedef struct { - cbm_zova_publish_node_vector_t value; -} model_node_vector_t; - -typedef struct { - cbm_zova_publish_token_vector_t value; -} model_token_vector_t; - -typedef struct { - cbm_sha256_backend_ctx metadata; - cbm_sha256_backend_ctx fts; - cbm_sha256_backend_ctx topology; - cbm_sha256_backend_ctx nodes; - cbm_sha256_backend_ctx tokens; -} model_digest_state_t; - -struct cbm_zova_publish_model { - char workspace_id[CBM_ZOVA_WORKSPACE_ID_MAX]; - cbm_zova_workspace_generation_input_t input; - model_node_t *nodes; - char *node_id_storage; - char *node_fts_storage; - int node_count; - const char **dense_ids; - uint64_t *dense_ordinals; - model_id_map_t *id_map; - model_edge_t *edges; - char *edge_id_storage; - int edge_count; - model_edge_t *topology; - int topology_count; - uint8_t *edge_payload_storage; - model_hash_t *hashes; - int hash_count; - model_node_vector_t *node_vectors; - int node_vector_count; - model_token_vector_t *token_vectors; - char *token_id_storage; - int token_vector_count; - cbm_zova_workspace_generation_result_t digests; - cbm_zova_publish_model_metrics_t metrics; - model_digest_state_t pending_digests; - bool prepared_pending; -}; - -/* Test hooks; atomic because index runs may build models concurrently. */ -static _Atomic int64_t model_fail_allocation_at = -1; -static _Atomic int64_t model_allocation_count = 0; -static _Atomic uint64_t model_build_count = 0; - -static void *model_malloc(size_t size) { - int64_t index = model_allocation_count++; - return index == model_fail_allocation_at ? NULL : malloc(size); -} - -static void *model_calloc(size_t count, size_t size) { - int64_t index = model_allocation_count++; - return index == model_fail_allocation_at ? NULL : calloc(count, size); -} - -void cbm_zova_publish_model_test_fail_allocation_at(int64_t index) { - model_fail_allocation_at = index; - model_allocation_count = 0; -} - -int64_t cbm_zova_publish_model_test_allocation_count(void) { - return model_allocation_count; -} - -void cbm_zova_publish_model_test_reset_build_count(void) { - model_build_count = 0; -} - -uint64_t cbm_zova_publish_model_test_build_count(void) { - return model_build_count; -} - -static double model_elapsed_ms(const struct timespec *started, - const struct timespec *finished) { - return (double)(finished->tv_sec - started->tv_sec) * 1000.0 + - (double)(finished->tv_nsec - started->tv_nsec) / 1000000.0; -} - -static int model_nonzero(const uint8_t *values, int length) { - if (!values || length < 0) return 0; - for (int i = 0; i < length; i++) - if (values[i] != 0) return 1; - return 0; -} - -static int model_node_id(const char *workspace_id, const CBMDumpNode *node, - char out[MODEL_ID_CAP]) { - if (!node || !node->label || !node->name || !node->qualified_name || !node->file_path) { - return -1; - } - size_t path_length = strlen(node->file_path); - size_t qualified_length = strlen(node->qualified_name); - if (path_length >= MODEL_PATH_CAP) return -1; - - cbm_sha256_ctx hash; - uint8_t digest[CBM_SHA256_DIGEST_LEN]; - char hex[33]; - static const char digits[] = "0123456789abcdef"; - cbm_sha256_init(&hash); - cbm_sha256_update(&hash, node->label, strlen(node->label)); - cbm_sha256_update(&hash, "\0", 1); - const char *path = node->file_path; - const char *path_end = path + path_length; - while (path < path_end) { - const char *separator = strchr(path, '\\'); - if (!separator || separator >= path_end) { - cbm_sha256_update(&hash, path, (size_t)(path_end - path)); - break; - } - cbm_sha256_update(&hash, path, (size_t)(separator - path)); - cbm_sha256_update(&hash, "/", 1); - path = separator + 1; - } - cbm_sha256_update(&hash, "\0", 1); - cbm_sha256_update(&hash, node->qualified_name, qualified_length); - cbm_sha256_update(&hash, "\0", 1); - if (node->qualified_name[0]) { - static const char prefix[] = "named:"; - if (qualified_length > MODEL_DISCRIMINATOR_CAP - sizeof(prefix)) return -1; - cbm_sha256_update(&hash, prefix, sizeof(prefix) - 1); - cbm_sha256_update(&hash, node->qualified_name, qualified_length); - } else { - char discriminator[MODEL_DISCRIMINATOR_CAP]; - if (!node->name[0] || node->start_line < 0 || node->end_line < node->start_line || - snprintf(discriminator, sizeof(discriminator), "local:%s:span:%d:%d", node->name, - node->start_line, node->end_line) >= (int)sizeof(discriminator)) return -1; - cbm_sha256_update(&hash, discriminator, strlen(discriminator)); - } - cbm_sha256_update(&hash, "\0", 1); - cbm_sha256_final(&hash, digest); - for (size_t i = 0; i < 16; i++) { - hex[i * 2] = digits[digest[i] >> 4]; - hex[i * 2 + 1] = digits[digest[i] & 0x0f]; - } - hex[32] = '\0'; - return snprintf(out, MODEL_ID_CAP, "n:v2:%s:%s", workspace_id, hex) < MODEL_ID_CAP - ? 0 : -1; -} - -static int model_hash_id(const char *prefix, const char *workspace_id, - const char *const *parts, size_t part_count, - char out[MODEL_ID_CAP]) { - cbm_sha256_ctx context; - uint8_t digest[CBM_SHA256_DIGEST_LEN]; - static const char digits[] = "0123456789abcdef"; - char hex[33]; - cbm_sha256_init(&context); - for (size_t i = 0; i < part_count; i++) { - if (!parts[i]) return -1; - cbm_sha256_update(&context, parts[i], strlen(parts[i])); - cbm_sha256_update(&context, "\0", 1); - } - cbm_sha256_final(&context, digest); - for (size_t i = 0; i < 16; i++) { - hex[i * 2] = digits[digest[i] >> 4]; - hex[i * 2 + 1] = digits[digest[i] & 0x0f]; - } - hex[32] = '\0'; - return snprintf(out, MODEL_ID_CAP, "%s:%s:%s", prefix, workspace_id, hex) < MODEL_ID_CAP - ? 0 : -1; -} - -static int model_edge_id(const char *workspace_id, const char *source, const char *type, - const char *target, const char *local_name, - char out[MODEL_ID_CAP]) { - const char *parts[] = {source, type, target, local_name}; - return model_hash_id("e:v1", workspace_id, parts, 4, out); -} - -static int model_token_id(const char *workspace_id, const char *token, - char out[MODEL_ID_CAP]) { - const char *parts[] = {token}; - return model_hash_id("t:v1", workspace_id, parts, 1, out); -} - -static int model_camel_boundary(const char *input, size_t length, size_t index) { - if (index == 0) return 0; - char current = input[index]; - char previous = input[index - 1]; - char next = index + 1 < length ? input[index + 1] : '\0'; - if (current >= 'A' && current <= 'Z' && previous >= 'a' && previous <= 'z') return 1; - return current >= 'A' && current <= 'Z' && previous >= 'A' && previous <= 'Z' && - next >= 'a' && next <= 'z'; -} - -static int model_camel_storage_size(const char *input, size_t *out_size, - size_t *out_length) { - if (!input || !out_size || !out_length) return -1; - size_t length = strlen(input); - size_t boundaries = 0; - for (size_t i = 0; i < length; i++) - if (model_camel_boundary(input, length, i)) boundaries++; - if (length > (SIZE_MAX - 2) / 2 || - boundaries > SIZE_MAX - (length * 2) - 2) return -1; - *out_size = length * 2 + boundaries + 2; - *out_length = length; - return 0; -} - -static int model_camel_name_into(const char *input, size_t length, char *value, - size_t capacity) { - if (!input || !value || length > (SIZE_MAX - 2) / 2 || capacity < length * 2 + 2) - return -1; - memcpy(value, input, length); - size_t output = length; - value[output++] = ' '; - for (size_t i = 0; i < length; i++) { - if (model_camel_boundary(input, length, i)) value[output++] = ' '; - value[output++] = input[i]; - } - value[output] = '\0'; - return 0; -} - -static int model_id_storage_layout(const char *workspace_id, int count, - size_t *out_stride, size_t *out_bytes) { - if (!workspace_id || count < 0 || !out_stride || !out_bytes) return -1; - size_t workspace_length = strlen(workspace_id); - /* n:v2::<32 hex>, e:v1:..., and t:v1:... are equal-width. */ - if (workspace_length > SIZE_MAX - 39) return -1; - size_t stride = workspace_length + 39; - if ((size_t)count > SIZE_MAX / stride) return -1; - *out_stride = stride; - *out_bytes = (size_t)count * stride; - return stride <= MODEL_ID_CAP ? 0 : -1; -} - -static int model_id_compare(const void *left_ptr, const void *right_ptr) { - const model_id_map_t *left = left_ptr; - const model_id_map_t *right = right_ptr; - return left->dump_id < right->dump_id ? -1 : left->dump_id > right->dump_id ? 1 : 0; -} - -static int model_node_compare(const void *left_ptr, const void *right_ptr) { - const model_node_t *left = left_ptr; - const model_node_t *right = right_ptr; - return strcmp(left->value.stable_id, right->value.stable_id); -} - -static int model_nullable_compare(const char *left, const char *right) { - return strcmp(left ? left : "", right ? right : ""); -} - -static void model_digest_text(cbm_sha256_backend_ctx *context, const char *value); - -static int model_edge_compare(const void *left_ptr, const void *right_ptr) { - const model_edge_t *left = left_ptr; - const model_edge_t *right = right_ptr; - int comparison = strcmp(left->value.edge_id, right->value.edge_id); - if (comparison != 0) return comparison; - comparison = strcmp(left->value.source_stable_id, right->value.source_stable_id); - if (comparison != 0) return comparison; - comparison = model_nullable_compare(left->value.source->type, right->value.source->type); - if (comparison != 0) return comparison; - comparison = strcmp(left->value.target_stable_id, right->value.target_stable_id); - if (comparison != 0) return comparison; - comparison = model_nullable_compare(left->value.source->properties, - right->value.source->properties); - if (comparison != 0) return comparison; - return model_nullable_compare(left->value.source->url_path, right->value.source->url_path); -} - -static int model_topology_compare(const void *left_ptr, const void *right_ptr) { - const model_edge_t *left = left_ptr; - const model_edge_t *right = right_ptr; - int comparison = left->value.source_ordinal < right->value.source_ordinal - ? -1 - : left->value.source_ordinal > right->value.source_ordinal ? 1 : 0; - if (comparison != 0) return comparison; - comparison = strcmp(left->value.source->type, right->value.source->type); - if (comparison != 0) return comparison; - return left->value.target_ordinal < right->value.target_ordinal - ? -1 - : left->value.target_ordinal > right->value.target_ordinal ? 1 : 0; -} - -typedef struct { - const CBMDumpEdge *source; - uint64_t source_ordinal; - uint64_t target_ordinal; -} prepared_topology_seed_t; - -static int prepared_topology_seed_compare(const void *left_ptr, - const void *right_ptr) { - const prepared_topology_seed_t *left = left_ptr; - const prepared_topology_seed_t *right = right_ptr; - if (left->source_ordinal != right->source_ordinal) - return left->source_ordinal < right->source_ordinal ? -1 : 1; - int comparison = left->source->type == right->source->type - ? 0 : strcmp(left->source->type, right->source->type); - if (comparison != 0) return comparison; - return left->target_ordinal < right->target_ordinal - ? -1 : left->target_ordinal > right->target_ordinal ? 1 : 0; -} - -typedef struct { - const CBMDumpEdge *source; - char edge_id[MODEL_ID_CAP]; -} prepared_group_edge_t; - -typedef struct { - size_t topology_index; - size_t edge_offset; - size_t edge_count; - size_t payload_size; -} prepared_payload_group_t; - -static int prepared_edge_payload_is_default(const CBMDumpEdge *edge) { - const char *properties = edge && edge->properties ? edge->properties : "{}"; - const char *url_path = edge && edge->url_path ? edge->url_path : ""; - const char *local_name = edge && edge->local_name ? edge->local_name : ""; - return edge && strcmp(properties, "{}") == 0 && url_path[0] == '\0' && - local_name[0] == '\0'; -} - -static int prepared_group_edge_compare(const void *left_ptr, const void *right_ptr) { - const prepared_group_edge_t *left = left_ptr; - const prepared_group_edge_t *right = right_ptr; - return strcmp(left->edge_id, right->edge_id); -} - -static void model_digest_topology_payload(model_digest_state_t *digests, - const cbm_zova_publish_edge_t *row) { - model_digest_text(&digests->metadata, row->source_stable_id); - model_digest_text(&digests->metadata, row->source->type); - model_digest_text(&digests->metadata, row->target_stable_id); - cbm_sha256_backend_update(&digests->metadata, row->payload, row->payload_len); -} - -static int model_hash_compare(const void *left_ptr, const void *right_ptr) { - const model_hash_t *left = left_ptr; - const model_hash_t *right = right_ptr; - return strcmp(left->source->file_path, right->source->file_path); -} - -static int model_node_vector_compare(const void *left_ptr, const void *right_ptr) { - const model_node_vector_t *left = left_ptr; - const model_node_vector_t *right = right_ptr; - return strcmp(left->value.stable_id, right->value.stable_id); -} - -static int model_token_vector_compare(const void *left_ptr, const void *right_ptr) { - const model_token_vector_t *left = left_ptr; - const model_token_vector_t *right = right_ptr; - return strcmp(left->value.token_id, right->value.token_id); -} - -static const char *model_lookup(const cbm_zova_publish_model_t *model, int64_t dump_id) { - if (model->dense_ids) { - if (dump_id <= 0 || dump_id > model->node_count) return NULL; - return model->dense_ids[dump_id - 1]; - } - int low = 0; - int high = model->node_count; - while (low < high) { - int middle = low + (high - low) / 2; - if (model->id_map[middle].dump_id == dump_id) return model->id_map[middle].stable_id; - if (model->id_map[middle].dump_id < dump_id) low = middle + 1; - else high = middle; - } - return NULL; -} - -static model_id_map_t *model_sparse_lookup(cbm_zova_publish_model_t *model, - int64_t dump_id) { - int low = 0; - int high = model->node_count; - while (low < high) { - int middle = low + (high - low) / 2; - if (model->id_map[middle].dump_id == dump_id) return &model->id_map[middle]; - if (model->id_map[middle].dump_id < dump_id) low = middle + 1; - else high = middle; - } - return NULL; -} - -static int model_lookup_ordinal(cbm_zova_publish_model_t *model, int64_t dump_id, - uint64_t *out_ordinal) { - if (!model || !out_ordinal) return -1; - if (model->dense_ids) { - if (dump_id <= 0 || dump_id > model->node_count || - !model->dense_ids[dump_id - 1] || !model->dense_ordinals) - return -1; - *out_ordinal = model->dense_ordinals[dump_id - 1]; - return 0; - } - model_id_map_t *entry = model_sparse_lookup(model, dump_id); - if (!entry) return -1; - *out_ordinal = entry->ordinal; - return 0; -} - -static void model_digest_text(cbm_sha256_backend_ctx *context, const char *value) { - const char *text = value ? value : ""; - cbm_sha256_backend_update(context, text, strlen(text)); - cbm_sha256_backend_update(context, "\0", 1); -} - -static void model_digest_i64(cbm_sha256_backend_ctx *context, int64_t value) { - uint8_t bytes[8]; - for (size_t i = 0; i < sizeof(bytes); i++) bytes[i] = (uint8_t)((uint64_t)value >> (i * 8)); - cbm_sha256_backend_update(context, bytes, sizeof(bytes)); -} - -static void model_digest_finish(cbm_sha256_backend_ctx *context, - char output[CBM_ZOVA_DIGEST_HEX_SIZE]) { - uint8_t digest[CBM_SHA256_DIGEST_LEN]; - static const char digits[] = "0123456789abcdef"; - cbm_sha256_backend_final(context, digest); - for (size_t i = 0; i < CBM_SHA256_DIGEST_LEN; i++) { - output[i * 2] = digits[digest[i] >> 4]; - output[i * 2 + 1] = digits[digest[i] & 0x0f]; - } - output[CBM_SHA256_HEX_LEN] = '\0'; -} - -static int model_validate_input(const char *workspace_id, - const cbm_zova_workspace_generation_input_t *input) { - if (!workspace_id || cbm_zova_workspace_id_validate(workspace_id) != 0 || !input || - !input->root_path || !input->root_path[0] || !input->project || !input->project[0] || - !input->indexed_at || !input->indexed_at[0] || !input->model_fingerprint || - !input->model_fingerprint[0] || input->vector_dimensions <= 0 || - input->node_count < 0 || input->edge_count < 0 || input->node_vector_count < 0 || - input->token_vector_count < 0 || input->file_hash_count < 0 || - (input->node_count && !input->nodes) || (input->edge_count && !input->edges) || - (input->node_vector_count && !input->node_vectors) || - (input->token_vector_count && !input->token_vectors) || - (input->file_hash_count && !input->file_hashes)) return -1; - if (input->project_summary.present && - (!input->project_summary.summary || !input->project_summary.source_hash || - !input->project_summary.created_at || !input->project_summary.updated_at)) return -1; - return 0; -} - -enum { MODEL_NODE_CHUNK_SIZE = 1024 }; - -typedef struct { - cbm_zova_publish_model_t *model; - size_t id_stride; - size_t fts_bytes; - uint8_t *failed; -} model_node_build_context_t; - -static void model_build_node_chunk(int chunk, void *opaque) { - model_node_build_context_t *context = opaque; - cbm_zova_publish_model_t *model = context->model; - int begin = chunk * MODEL_NODE_CHUNK_SIZE; - int end = begin + MODEL_NODE_CHUNK_SIZE; - if (end > model->node_count) end = model->node_count; - for (int i = begin; i < end; i++) { - model_node_t *row = &model->nodes[i]; - const CBMDumpNode *node = row->value.source; - char *stable_id = model->node_id_storage + (size_t)i * context->id_stride; - char *fts_end = i + 1 < model->node_count - ? model->nodes[i + 1].fts_name - : model->node_fts_storage + context->fts_bytes; - size_t fts_capacity = (size_t)(fts_end - row->fts_name); - size_t name_length = (size_t)row->value.ordinal; - if (model_node_id(model->workspace_id, node, stable_id) != 0 || - model_camel_name_into(node->name, name_length, row->fts_name, - fts_capacity) != 0) { - context->failed[chunk] = 1; - return; - } - row->value.stable_id = stable_id; - row->value.ordinal = 0; - } -} - -static int model_build_nodes(cbm_zova_publish_model_t *model, - model_digest_state_t *digests) { - int count = model->input.node_count; - if (count == 0) return 0; - model->nodes = model_calloc((size_t)count, sizeof(*model->nodes)); - model->dense_ids = model_calloc((size_t)count, sizeof(*model->dense_ids)); - model->dense_ordinals = model_calloc((size_t)count, sizeof(*model->dense_ordinals)); - if (!model->nodes || !model->dense_ids || !model->dense_ordinals) return -1; - size_t id_stride = 0; - size_t id_bytes = 0; - size_t fts_bytes = 0; - if (model_id_storage_layout(model->workspace_id, count, &id_stride, &id_bytes) != 0) - return -1; - for (int i = 0; i < count; i++) { - size_t row_bytes = 0; - size_t name_length = 0; - if (!model->input.nodes[i].name || - model_camel_storage_size(model->input.nodes[i].name, &row_bytes, - &name_length) != 0 || - row_bytes > SIZE_MAX - fts_bytes) - return -1; - model->nodes[i].value.ordinal = (uint64_t)row_bytes; - model->nodes[i].value.source_ordinal = (uint64_t)name_length; - fts_bytes += row_bytes; - } - model->node_id_storage = model_malloc(id_bytes); - model->node_fts_storage = model_malloc(fts_bytes); - if (!model->nodes || !model->dense_ids || !model->dense_ordinals || - !model->node_id_storage || !model->node_fts_storage) - return -1; - model->node_count = count; - int dense_ids = 1; - char *fts_cursor = model->node_fts_storage; - for (int i = 0; i < count; i++) { - const CBMDumpNode *node = &model->input.nodes[i]; - char *stable_id = model->node_id_storage + (size_t)i * id_stride; - size_t fts_capacity = (size_t)model->nodes[i].value.ordinal; - size_t name_length = (size_t)model->nodes[i].value.source_ordinal; - if (node->id <= 0 || !node->project || strcmp(node->project, model->input.project) != 0 || - !node->label || !node->name || !node->qualified_name || !node->file_path) { - return -1; - } - model->nodes[i].value = (cbm_zova_publish_node_t){ - .source = node, .stable_id = stable_id, .ordinal = (uint64_t)name_length, - .source_ordinal = (uint64_t)i}; - model->nodes[i].fts_name = fts_cursor; - fts_cursor += fts_capacity; - if (node->id > count || model->dense_ids[node->id - 1]) { - dense_ids = 0; - } else { - model->dense_ids[node->id - 1] = stable_id; - } - } - int chunk_count = (count + MODEL_NODE_CHUNK_SIZE - 1) / MODEL_NODE_CHUNK_SIZE; - uint8_t *failed = model_calloc((size_t)chunk_count, sizeof(*failed)); - if (!failed) return -1; - model_node_build_context_t context = { - .model = model, .id_stride = id_stride, .fts_bytes = fts_bytes, - .failed = failed}; - cbm_parallel_for(chunk_count, model_build_node_chunk, &context, - (cbm_parallel_for_opts_t){0}); - for (int i = 0; i < chunk_count; i++) { - if (failed[i]) { - free(failed); - return -1; - } - } - free(failed); - model->metrics.stable_node_id_computations += (uint64_t)count; - model->metrics.camel_split_computations += (uint64_t)count; - if (!dense_ids) { - free(model->dense_ids); - model->dense_ids = NULL; - free(model->dense_ordinals); - model->dense_ordinals = NULL; - model->id_map = model_calloc((size_t)count, sizeof(*model->id_map)); - if (!model->id_map) return -1; - for (int i = 0; i < count; i++) { - model->id_map[i] = (model_id_map_t){ - .dump_id = model->nodes[i].value.source->id, - .stable_id = model->nodes[i].value.stable_id}; - } - if (count > 1) { - qsort(model->id_map, (size_t)count, sizeof(*model->id_map), model_id_compare); - } - model->metrics.global_sorts++; - for (int i = 1; i < count; i++) - if (model->id_map[i - 1].dump_id == model->id_map[i].dump_id) return -1; - } - if (count > 1) { - qsort(model->nodes, (size_t)count, sizeof(*model->nodes), model_node_compare); - } - model->metrics.global_sorts++; - for (int i = 0; i < count; i++) { - if (i > 0 && strcmp(model->nodes[i - 1].value.stable_id, - model->nodes[i].value.stable_id) == 0) return -1; - model->nodes[i].value.ordinal = (uint64_t)i; - int64_t dump_id = model->nodes[i].value.source->id; - if (model->dense_ids) { - model->dense_ordinals[dump_id - 1] = (uint64_t)i; - } else { - model_id_map_t *entry = model_sparse_lookup(model, dump_id); - if (!entry) return -1; - entry->ordinal = (uint64_t)i; - } - const model_node_t *row = &model->nodes[i]; - const CBMDumpNode *node = row->value.source; - model_digest_text(&digests->metadata, row->value.stable_id); - model_digest_text(&digests->metadata, node->project); - model_digest_text(&digests->metadata, node->label); - model_digest_text(&digests->metadata, node->name); - model_digest_text(&digests->metadata, node->qualified_name); - model_digest_text(&digests->metadata, node->file_path); - model_digest_i64(&digests->metadata, node->start_line); - model_digest_i64(&digests->metadata, node->end_line); - model_digest_text(&digests->metadata, node->properties); - model_digest_text(&digests->fts, row->value.stable_id); - model_digest_text(&digests->fts, row->fts_name); - model_digest_text(&digests->fts, node->qualified_name); - model_digest_text(&digests->fts, node->label); - model_digest_text(&digests->fts, node->file_path); - } - return 0; -} - -static int model_build_edges(cbm_zova_publish_model_t *model, - model_digest_state_t *digests) { - int count = model->input.edge_count; - if (count == 0) return 0; - model->edges = model_calloc((size_t)count, sizeof(*model->edges)); - size_t id_stride = 0; - size_t id_bytes = 0; - if (model_id_storage_layout(model->workspace_id, count, &id_stride, &id_bytes) != 0) - return -1; - model->edge_id_storage = model_malloc(id_bytes); - if (!model->edges || !model->edge_id_storage) return -1; - model->edge_count = count; - for (int i = 0; i < count; i++) { - const CBMDumpEdge *edge = &model->input.edges[i]; - const char *source = model_lookup(model, edge->source_id); - const char *target = model_lookup(model, edge->target_id); - uint64_t source_ordinal = 0; - uint64_t target_ordinal = 0; - model->metrics.endpoint_lookups += 2; - if (!edge->project || strcmp(edge->project, model->input.project) != 0 || - !edge->type || !edge->type[0] || !source || !target) return -1; - if (model_lookup_ordinal(model, edge->source_id, &source_ordinal) != 0 || - model_lookup_ordinal(model, edge->target_id, &target_ordinal) != 0) - return -1; - char *edge_id = model->edge_id_storage + (size_t)i * id_stride; - if (model_edge_id(model->workspace_id, source, edge->type, target, - edge->local_name ? edge->local_name : "", edge_id) != 0) return -1; - model->edges[i].value = (cbm_zova_publish_edge_t){ - .source = edge, .edge_id = edge_id, .source_stable_id = source, - .target_stable_id = target, .source_ordinal = source_ordinal, - .target_ordinal = target_ordinal}; - model->metrics.edge_id_computations++; - } - if (count > 1) { - qsort(model->edges, (size_t)count, sizeof(*model->edges), model_edge_compare); - } - model->metrics.global_sorts++; - int unique_edges = 0; - for (int i = 0; i < count; i++) { - if (unique_edges > 0 && strcmp(model->edges[unique_edges - 1].value.edge_id, - model->edges[i].value.edge_id) == 0) { - const cbm_zova_publish_edge_t *previous = &model->edges[unique_edges - 1].value; - const cbm_zova_publish_edge_t *current = &model->edges[i].value; - if (strcmp(previous->source_stable_id, current->source_stable_id) != 0 || - strcmp(previous->source->type, current->source->type) != 0 || - strcmp(previous->target_stable_id, current->target_stable_id) != 0 || - model_nullable_compare(previous->source->local_name, - current->source->local_name) != 0) return -1; - model->edges[unique_edges - 1] = model->edges[i]; - } else { - if (unique_edges != i) model->edges[unique_edges] = model->edges[i]; - unique_edges++; - } - } - model->edge_count = unique_edges; - for (int i = 0; i < unique_edges; i++) { - cbm_zova_publish_edge_t *row = &model->edges[i].value; - row->ordinal = (uint64_t)i; - } - - model->topology = model_calloc((size_t)unique_edges, sizeof(*model->topology)); - if (!model->topology) return -1; - for (int i = 0; i < unique_edges; i++) model->topology[i] = model->edges[i]; - if (unique_edges > 1) { - qsort(model->topology, (size_t)unique_edges, sizeof(*model->topology), - model_topology_compare); - } - model->metrics.global_sorts++; - int unique_topology = 0; - for (int i = 0; i < unique_edges; i++) { - model_edge_t current = model->topology[i]; - if (unique_topology == 0 || - model_topology_compare(&model->topology[unique_topology - 1], ¤t) != 0) { - if (unique_topology != i) model->topology[unique_topology] = current; - model->topology[unique_topology].value.ordinal = (uint64_t)unique_topology; - const cbm_zova_publish_edge_t *row = &model->topology[unique_topology].value; - model_digest_text(&digests->topology, row->source_stable_id); - model_digest_text(&digests->topology, row->source->type); - model_digest_text(&digests->topology, row->target_stable_id); - unique_topology++; - } - if (current.value.ordinal >= (uint64_t)unique_edges || unique_topology <= 0) return -1; - model->edges[current.value.ordinal].value.topology_ordinal = - (uint64_t)(unique_topology - 1); - } - model->topology_count = unique_topology; - - size_t *counts = model_calloc((size_t)unique_topology * 3, sizeof(*counts)); - const CBMDumpEdge **grouped = model_malloc((size_t)unique_edges * sizeof(*grouped)); - if (!counts || !grouped) { - free(counts); - free(grouped); - return -1; - } - size_t *offsets = counts + unique_topology; - size_t *cursors = offsets + unique_topology; - for (int i = 0; i < unique_edges; i++) { - uint64_t ordinal = model->edges[i].value.topology_ordinal; - if (ordinal >= (uint64_t)unique_topology) { - free(counts); - free(grouped); - return -1; - } - counts[ordinal]++; - } - size_t grouped_count = 0; - for (int i = 0; i < unique_topology; i++) { - offsets[i] = grouped_count; - cursors[i] = grouped_count; - if (counts[i] > SIZE_MAX - grouped_count) { - free(counts); - free(grouped); - return -1; - } - grouped_count += counts[i]; - } - if (grouped_count != (size_t)unique_edges) { - free(counts); - free(grouped); - return -1; - } - for (int i = 0; i < unique_edges; i++) { - size_t ordinal = (size_t)model->edges[i].value.topology_ordinal; - grouped[cursors[ordinal]++] = model->edges[i].value.source; - } - - size_t payload_bytes = 0; - for (int i = 0; i < unique_topology; i++) { - size_t payload_size = 0; - if (cbm_zova_edge_payload_encoded_size(grouped + offsets[i], counts[i], - &payload_size) != 0 || - payload_size > SIZE_MAX - payload_bytes) { - free(counts); - free(grouped); - return -1; - } - cursors[i] = payload_size; - payload_bytes += payload_size; - } - if (payload_bytes > 0) { - model->edge_payload_storage = model_malloc(payload_bytes); - if (!model->edge_payload_storage) { - free(counts); - free(grouped); - return -1; - } - } - uint8_t *payload_cursor = model->edge_payload_storage; - for (int i = 0; i < unique_topology; i++) { - cbm_zova_publish_edge_t *topology = &model->topology[i].value; - topology->logical_edge_count = counts[i]; - topology->payload = cursors[i] > 0 ? payload_cursor : NULL; - topology->payload_len = cursors[i]; - size_t encoded = 0; - if (cbm_zova_edge_payload_encode(grouped + offsets[i], counts[i], - payload_cursor, cursors[i], &encoded) != 0 || - encoded != cursors[i]) { - free(counts); - free(grouped); - return -1; - } - if (encoded > 0) payload_cursor += encoded; - model_digest_topology_payload(digests, topology); - } - free(counts); - free(grouped); - return 0; -} - -/* Build only the topology rows required by authoritative full publication. - * Finalized graph-buffer input already has dense node IDs and deduplicated - * logical edges, so delta-only logical edge IDs and edge ordering are omitted. */ -static int model_build_prepared_topology(cbm_zova_publish_model_t *model, - model_digest_state_t *digests) { - int count = model->input.edge_count; - model->edge_count = count; - if (count == 0) return 0; - if (!model->dense_ids || !model->dense_ordinals) return -1; - struct timespec phase_started = {0}, phase_finished = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_started); - model->topology = model_calloc((size_t)count, sizeof(*model->topology)); - prepared_topology_seed_t *seeds = - model_malloc((size_t)count * sizeof(*seeds)); - prepared_payload_group_t *payload_groups = NULL; - const CBMDumpEdge **payload_edges = NULL; - prepared_group_edge_t *ordered = NULL; - if (!model->topology || !seeds) { - free(seeds); - return -1; - } - for (int i = 0; i < count; i++) { - const CBMDumpEdge *edge = &model->input.edges[i]; - const char *source = NULL; - const char *target = NULL; - uint64_t source_ordinal = 0; - uint64_t target_ordinal = 0; - model->metrics.endpoint_lookups += 2; - if (edge->source_id <= 0 || edge->source_id > model->node_count || - edge->target_id <= 0 || edge->target_id > model->node_count) { - goto fail; - } else { - size_t source_index = (size_t)(edge->source_id - 1); - size_t target_index = (size_t)(edge->target_id - 1); - source = model->dense_ids[source_index]; - target = model->dense_ids[target_index]; - source_ordinal = model->dense_ordinals[source_index]; - target_ordinal = model->dense_ordinals[target_index]; - } - if (!edge->project || - (edge->project != model->input.project && - strcmp(edge->project, model->input.project) != 0) || - !edge->type || !edge->type[0] || !source || !target) { - goto fail; - } - seeds[i] = (prepared_topology_seed_t){ - .source = edge, - .source_ordinal = source_ordinal, - .target_ordinal = target_ordinal, - }; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_finished); - model->metrics.prepared_endpoint_ms = - model_elapsed_ms(&phase_started, &phase_finished); - phase_started = phase_finished; - if (count > 1) { - qsort(seeds, (size_t)count, sizeof(*seeds), prepared_topology_seed_compare); - } - model->metrics.global_sorts++; - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_finished); - model->metrics.prepared_topology_sort_ms = - model_elapsed_ms(&phase_started, &phase_finished); - phase_started = phase_finished; - - int unique_topology = 0; - size_t payload_group_count = 0; - size_t payload_edge_count = 0; - size_t payload_bytes = 0; - for (int i = 0; i < count;) { - int end = i + 1; - while (end < count && - prepared_topology_seed_compare(&seeds[i], &seeds[end]) == 0) - end++; - const CBMDumpEdge *edge = seeds[i].source; - cbm_zova_publish_edge_t row = { - .source = edge, - .source_stable_id = model->dense_ids[edge->source_id - 1], - .target_stable_id = model->dense_ids[edge->target_id - 1], - .source_ordinal = seeds[i].source_ordinal, - .target_ordinal = seeds[i].target_ordinal, - }; - row.ordinal = (uint64_t)unique_topology; - row.logical_edge_count = (uint64_t)(end - i); - model->topology[unique_topology].value = row; - size_t group_edge_count = (size_t)(end - i); - if (group_edge_count == 1 && prepared_edge_payload_is_default(edge)) { - model->metrics.prepared_single_default_payload_count++; - } else if (group_edge_count == 1) { - const CBMDumpEdge *single_edge = edge; - if (cbm_zova_edge_payload_encoded_size( - &single_edge, 1, &row.payload_len) != 0 || - row.payload_len > SIZE_MAX - payload_bytes) - goto fail; - model->topology[unique_topology].value.payload_len = row.payload_len; - payload_bytes += row.payload_len; - } else { - payload_group_count++; - if (group_edge_count > SIZE_MAX - payload_edge_count) goto fail; - payload_edge_count += group_edge_count; - } - unique_topology++; - i = end; - } - model->topology_count = unique_topology; - model->metrics.prepared_payload_scratch_edge_count = payload_edge_count; - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_finished); - model->metrics.prepared_topology_group_ms = - model_elapsed_ms(&phase_started, &phase_finished); - phase_started = phase_finished; - - if (payload_group_count > 0) { - payload_groups = model_calloc(payload_group_count, sizeof(*payload_groups)); - payload_edges = model_malloc(payload_edge_count * sizeof(*payload_edges)); - if (!payload_groups || !payload_edges) goto fail; - } - size_t payload_group_index = 0; - size_t payload_edge_offset = 0; - size_t topology_index = 0; - for (int i = 0; i < count;) { - int end = i + 1; - while (end < count && - prepared_topology_seed_compare(&seeds[i], &seeds[end]) == 0) - end++; - size_t group_edge_count = (size_t)(end - i); - if (group_edge_count > 1) { - prepared_payload_group_t *group = &payload_groups[payload_group_index++]; - *group = (prepared_payload_group_t){ - .topology_index = topology_index, - .edge_offset = payload_edge_offset, - .edge_count = group_edge_count, - }; - for (size_t j = 0; j < group_edge_count; j++) - payload_edges[payload_edge_offset + j] = seeds[i + (int)j].source; - ordered = model_malloc(group_edge_count * sizeof(*ordered)); - if (!ordered) goto fail; - const cbm_zova_publish_edge_t *topology = - &model->topology[topology_index].value; - for (size_t j = 0; j < group_edge_count; j++) { - ordered[j].source = payload_edges[payload_edge_offset + j]; - if (model_edge_id(model->workspace_id, topology->source_stable_id, - topology->source->type, - topology->target_stable_id, - ordered[j].source->local_name - ? ordered[j].source->local_name - : "", - ordered[j].edge_id) != 0) - goto fail; - model->metrics.edge_id_computations++; - } - if (group_edge_count > 1) { - qsort(ordered, group_edge_count, sizeof(*ordered), - prepared_group_edge_compare); - } - for (size_t j = 0; j < group_edge_count; j++) { - if (j > 0 && strcmp(ordered[j - 1].edge_id, - ordered[j].edge_id) == 0) - goto fail; - payload_edges[payload_edge_offset + j] = ordered[j].source; - } - free(ordered); - ordered = NULL; - if (cbm_zova_edge_payload_encoded_size( - payload_edges + payload_edge_offset, group_edge_count, - &group->payload_size) != 0 || - group->payload_size > SIZE_MAX - payload_bytes) - goto fail; - model->topology[topology_index].value.payload_len = group->payload_size; - payload_bytes += group->payload_size; - payload_edge_offset += group_edge_count; - } - topology_index++; - i = end; - } - if (payload_group_index != payload_group_count || - payload_edge_offset != payload_edge_count) - goto fail; - if (payload_bytes > 0) { - model->edge_payload_storage = model_malloc(payload_bytes); - if (!model->edge_payload_storage) goto fail; - } - uint8_t *payload_cursor = model->edge_payload_storage; - size_t duplicate_group_index = 0; - for (int i = 0; i < unique_topology; i++) { - cbm_zova_publish_edge_t *row = &model->topology[i].value; - if (row->payload_len == 0) continue; - const CBMDumpEdge *single_edge = row->source; - const CBMDumpEdge *const *edges = &single_edge; - size_t edge_count = 1; - if (row->logical_edge_count > 1) { - prepared_payload_group_t *group = - &payload_groups[duplicate_group_index++]; - if (group->topology_index != (size_t)i || - group->payload_size != row->payload_len) - goto fail; - edges = payload_edges + group->edge_offset; - edge_count = group->edge_count; - } - row->payload = payload_cursor; - size_t encoded = 0; - if (cbm_zova_edge_payload_encode( - edges, edge_count, payload_cursor, row->payload_len, &encoded) != 0 || - encoded != row->payload_len) - goto fail; - if (encoded > 0) payload_cursor += encoded; - } - if (duplicate_group_index != payload_group_count) goto fail; - free(payload_edges); - payload_edges = NULL; - free(payload_groups); - payload_groups = NULL; - free(seeds); - seeds = NULL; - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_finished); - model->metrics.prepared_payload_ms = - model_elapsed_ms(&phase_started, &phase_finished); - phase_started = phase_finished; - - for (int i = 0; i < unique_topology; i++) { - const cbm_zova_publish_edge_t *row = &model->topology[i].value; - model_digest_text(&digests->topology, row->source_stable_id); - model_digest_text(&digests->topology, row->source->type); - model_digest_text(&digests->topology, row->target_stable_id); - model_digest_topology_payload(digests, row); - } - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_finished); - model->metrics.prepared_topology_digest_ms = - model_elapsed_ms(&phase_started, &phase_finished); - return 0; - -fail: - free(ordered); - free(payload_edges); - free(payload_groups); - free(seeds); - return -1; -} - -static int model_build_hashes(cbm_zova_publish_model_t *model, - model_digest_state_t *digests) { - int count = model->input.file_hash_count; - if (count == 0) return 0; - model->hashes = model_calloc((size_t)count, sizeof(*model->hashes)); - if (!model->hashes) return -1; - model->hash_count = count; - for (int i = 0; i < count; i++) { - const cbm_zova_file_hash_input_t *hash = &model->input.file_hashes[i]; - if (!hash->file_path || !hash->content_hash) return -1; - model->hashes[i].source = hash; - } - if (count > 1) { - qsort(model->hashes, (size_t)count, sizeof(*model->hashes), model_hash_compare); - } - model->metrics.global_sorts++; - for (int i = 0; i < count; i++) { - if (i > 0 && strcmp(model->hashes[i - 1].source->file_path, - model->hashes[i].source->file_path) == 0) return -1; - const cbm_zova_file_hash_input_t *hash = model->hashes[i].source; - model_digest_text(&digests->metadata, hash->file_path); - model_digest_text(&digests->metadata, hash->content_hash); - model_digest_i64(&digests->metadata, hash->mtime_ns); - model_digest_i64(&digests->metadata, hash->size_bytes); - } - return 0; -} - -static int model_build_vectors(cbm_zova_publish_model_t *model, - model_digest_state_t *digests) { - int node_capacity = model->input.node_vector_count; - int token_capacity = model->input.token_vector_count; - size_t token_id_stride = 0; - size_t token_id_bytes = 0; - if (node_capacity > 0) { - model->node_vectors = model_calloc((size_t)node_capacity, sizeof(*model->node_vectors)); - if (!model->node_vectors) return -1; - } - for (int i = 0; i < node_capacity; i++) { - const CBMDumpVector *vector = &model->input.node_vectors[i]; - const char *stable_id = model_lookup(model, vector->node_id); - uint64_t node_ordinal = 0; - if (!vector->project || strcmp(vector->project, model->input.project) != 0 || - !stable_id || model_lookup_ordinal(model, vector->node_id, &node_ordinal) != 0 || - !vector->vector || vector->vector_len != model->input.vector_dimensions) - return -1; - if (!model_nonzero(vector->vector, vector->vector_len)) continue; - model->node_vectors[model->node_vector_count++].value = - (cbm_zova_publish_node_vector_t){ - .source = vector, .stable_id = stable_id, .node_ordinal = node_ordinal}; - } - if (model->node_vector_count > 0) { - if (model->node_vector_count > 1) { - qsort(model->node_vectors, (size_t)model->node_vector_count, - sizeof(*model->node_vectors), model_node_vector_compare); - } - model->metrics.global_sorts++; - for (int i = 0; i < model->node_vector_count; i++) { - if (i > 0 && strcmp(model->node_vectors[i - 1].value.stable_id, - model->node_vectors[i].value.stable_id) == 0) return -1; - model->node_vectors[i].value.ordinal = (uint64_t)i; - const cbm_zova_publish_node_vector_t *row = &model->node_vectors[i].value; - model_digest_text(&digests->nodes, row->stable_id); - cbm_sha256_backend_update(&digests->nodes, row->source->vector, - (size_t)row->source->vector_len); - } - } - if (token_capacity > 0) { - model->token_vectors = model_calloc((size_t)token_capacity, - sizeof(*model->token_vectors)); - if (model_id_storage_layout(model->workspace_id, token_capacity, - &token_id_stride, &token_id_bytes) != 0) - return -1; - model->token_id_storage = model_malloc(token_id_bytes); - if (!model->token_vectors || !model->token_id_storage) return -1; - } - for (int i = 0; i < token_capacity; i++) { - const CBMDumpTokenVec *vector = &model->input.token_vectors[i]; - if (vector->id <= 0 || !vector->project || - strcmp(vector->project, model->input.project) != 0 || !vector->token || - !vector->vector || vector->vector_len != model->input.vector_dimensions || - !isfinite(vector->idf)) return -1; - char *token_id = model->token_id_storage + (size_t)i * token_id_stride; - if (model_token_id(model->workspace_id, vector->token, token_id) != 0) return -1; - model->metrics.token_id_computations++; - if (!model_nonzero(vector->vector, vector->vector_len)) continue; - model->token_vectors[model->token_vector_count++].value = - (cbm_zova_publish_token_vector_t){.source = vector, .token_id = token_id}; - } - if (model->token_vector_count > 0) { - if (model->token_vector_count > 1) { - qsort(model->token_vectors, (size_t)model->token_vector_count, - sizeof(*model->token_vectors), model_token_vector_compare); - } - model->metrics.global_sorts++; - for (int i = 0; i < model->token_vector_count; i++) { - if (i > 0 && strcmp(model->token_vectors[i - 1].value.token_id, - model->token_vectors[i].value.token_id) == 0) return -1; - model->token_vectors[i].value.ordinal = (uint64_t)i; - const cbm_zova_publish_token_vector_t *row = &model->token_vectors[i].value; - model_digest_text(&digests->tokens, row->token_id); - model_digest_text(&digests->tokens, row->source->token); - cbm_sha256_backend_update(&digests->tokens, &row->source->idf, - sizeof(row->source->idf)); - cbm_sha256_backend_update(&digests->tokens, row->source->vector, - (size_t)row->source->vector_len); - } - } - return 0; -} - -static void model_digest_summary(cbm_zova_publish_model_t *model, - model_digest_state_t *digests) { - model_digest_text(&digests->metadata, - model->input.project_summary.present ? "1" : "0"); - if (model->input.project_summary.present) { - model_digest_text(&digests->metadata, model->input.project_summary.summary); - model_digest_text(&digests->metadata, model->input.project_summary.source_hash); - model_digest_text(&digests->metadata, model->input.project_summary.created_at); - model_digest_text(&digests->metadata, model->input.project_summary.updated_at); - } -} - -static void model_digest_finish_all(cbm_zova_publish_model_t *model, - model_digest_state_t *digests) { - model_digest_finish(&digests->metadata, model->digests.metadata_sha256); - model_digest_finish(&digests->fts, model->digests.fts_sha256); - model_digest_finish(&digests->topology, model->digests.topology_sha256); - model_digest_finish(&digests->nodes, model->digests.node_vector_sha256); - model_digest_finish(&digests->tokens, model->digests.token_vector_sha256); - model->digests.graph_nodes = (uint64_t)model->node_count; - model->digests.graph_edges = (uint64_t)model->topology_count; - model->digests.metadata_nodes = (uint64_t)model->node_count; - model->digests.metadata_edges = (uint64_t)model->edge_count; - model->digests.metadata_topology_edges = (uint64_t)model->topology_count; - model->digests.fts_rows = (uint64_t)model->node_count; - model->digests.node_vectors = (uint64_t)model->node_vector_count; - model->digests.node_vector_rows = (uint64_t)model->node_vector_count; - model->digests.token_vectors = (uint64_t)model->token_vector_count; - model->digests.token_vector_rows = (uint64_t)model->token_vector_count; -} - -int cbm_zova_publish_model_build( - const char *workspace_id, const cbm_zova_workspace_generation_input_t *input, - cbm_zova_publish_model_t **out_model) { - struct timespec started = {0}, phase_started = {0}, finished = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - if (!out_model) return -1; - *out_model = NULL; - if (model_validate_input(workspace_id, input) != 0) return -1; - model_allocation_count = 0; - cbm_zova_publish_model_t *model = model_calloc(1, sizeof(*model)); - if (!model) return -1; - snprintf(model->workspace_id, sizeof(model->workspace_id), "%s", workspace_id); - model->input = *input; - model_digest_state_t digests; - cbm_sha256_backend_init(&digests.metadata); - cbm_sha256_backend_init(&digests.fts); - cbm_sha256_backend_init(&digests.topology); - cbm_sha256_backend_init(&digests.nodes); - cbm_sha256_backend_init(&digests.tokens); - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_started); - if (model_build_nodes(model, &digests) != 0) { - cbm_zova_publish_model_free(model); - return -1; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - model->metrics.nodes_ms = model_elapsed_ms(&phase_started, &finished); - phase_started = finished; - if (model_build_edges(model, &digests) != 0) { - cbm_zova_publish_model_free(model); - return -1; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - model->metrics.edges_ms = model_elapsed_ms(&phase_started, &finished); - phase_started = finished; - if (model_build_hashes(model, &digests) != 0) { - cbm_zova_publish_model_free(model); - return -1; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - model->metrics.hashes_ms = model_elapsed_ms(&phase_started, &finished); - model_digest_summary(model, &digests); - phase_started = finished; - if (model_build_vectors(model, &digests) != 0) { - cbm_zova_publish_model_free(model); - return -1; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - model->metrics.vectors_ms = model_elapsed_ms(&phase_started, &finished); - phase_started = finished; - model_digest_finish_all(model, &digests); - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - model->metrics.digests_ms = model_elapsed_ms(&phase_started, &finished); - model->metrics.normalization_ms = model_elapsed_ms(&started, &finished); - model_build_count++; - *out_model = model; - return 0; -} - -int cbm_zova_prepared_view_build_base( - const char *workspace_id, const cbm_zova_workspace_generation_input_t *input, - cbm_zova_prepared_view_t **out_view) { - struct timespec started = {0}, phase_started = {0}, finished = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - if (!out_view) return -1; - *out_view = NULL; - if (model_validate_input(workspace_id, input) != 0 || - input->file_hash_count != 0 || input->file_hashes || - input->project_summary.present) return -1; - model_allocation_count = 0; - cbm_zova_publish_model_t *view = model_calloc(1, sizeof(*view)); - if (!view) return -1; - snprintf(view->workspace_id, sizeof(view->workspace_id), "%s", workspace_id); - view->input = *input; - model_digest_state_t *digests = &view->pending_digests; - cbm_sha256_backend_init(&digests->metadata); - cbm_sha256_backend_init(&digests->fts); - cbm_sha256_backend_init(&digests->topology); - cbm_sha256_backend_init(&digests->nodes); - cbm_sha256_backend_init(&digests->tokens); - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_started); - if (model_build_nodes(view, digests) != 0 || - (view->node_count > 0 && (!view->dense_ids || !view->dense_ordinals))) { - cbm_zova_publish_model_free(view); - return -1; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - view->metrics.nodes_ms = model_elapsed_ms(&phase_started, &finished); - phase_started = finished; - if (model_build_prepared_topology(view, digests) != 0) { - cbm_zova_publish_model_free(view); - return -1; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - view->metrics.edges_ms = model_elapsed_ms(&phase_started, &finished); - phase_started = finished; - if (model_build_vectors(view, digests) != 0) { - cbm_zova_publish_model_free(view); - return -1; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - view->metrics.vectors_ms = model_elapsed_ms(&phase_started, &finished); - view->metrics.normalization_ms = model_elapsed_ms(&started, &finished); - view->prepared_pending = true; - *out_view = view; - return 0; -} - -int cbm_zova_prepared_view_complete( - cbm_zova_prepared_view_t *view, - const cbm_zova_file_hash_input_t *file_hashes, int file_hash_count, - const cbm_zova_project_summary_input_t *project_summary) { - if (!view || !view->prepared_pending || file_hash_count < 0 || - (file_hash_count > 0 && !file_hashes)) return -1; - cbm_zova_project_summary_input_t summary = project_summary - ? *project_summary : (cbm_zova_project_summary_input_t){0}; - if (summary.present && - (!summary.summary || !summary.source_hash || - !summary.created_at || !summary.updated_at)) return -1; - view->input.file_hashes = file_hashes; - view->input.file_hash_count = file_hash_count; - view->input.project_summary = summary; - struct timespec started = {0}, phase = {0}, finished = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - phase = started; - if (model_build_hashes(view, &view->pending_digests) != 0) return -1; - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - view->metrics.hashes_ms = model_elapsed_ms(&phase, &finished); - model_digest_summary(view, &view->pending_digests); - phase = finished; - model_digest_finish_all(view, &view->pending_digests); - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - view->metrics.digests_ms = model_elapsed_ms(&phase, &finished); - view->metrics.normalization_ms += model_elapsed_ms(&started, &finished); - view->prepared_pending = false; - return 0; -} - -int cbm_zova_prepared_view_build( - const char *workspace_id, const cbm_zova_workspace_generation_input_t *input, - cbm_zova_prepared_view_t **out_view) { - if (!input || !out_view) return -1; - *out_view = NULL; - cbm_zova_workspace_generation_input_t base = *input; - base.file_hashes = NULL; - base.file_hash_count = 0; - base.project_summary = (cbm_zova_project_summary_input_t){0}; - cbm_zova_prepared_view_t *view = NULL; - if (cbm_zova_prepared_view_build_base(workspace_id, &base, &view) != 0) - return -1; - if (cbm_zova_prepared_view_complete( - view, input->file_hashes, input->file_hash_count, - &input->project_summary) != 0) { - cbm_zova_prepared_view_free(view); - return -1; - } - *out_view = view; - return 0; -} - -void cbm_zova_prepared_view_free(cbm_zova_prepared_view_t *view) { - cbm_zova_publish_model_free(view); -} - -void cbm_zova_publish_model_free(cbm_zova_publish_model_t *model) { - if (!model) return; - free(model->nodes); - free(model->node_id_storage); - free(model->node_fts_storage); - free(model->dense_ids); - free(model->dense_ordinals); - free(model->id_map); - free(model->edges); - free(model->edge_id_storage); - free(model->topology); - free(model->edge_payload_storage); - free(model->hashes); - free(model->node_vectors); - free(model->token_vectors); - free(model->token_id_storage); - free(model); -} - -const char *cbm_zova_publish_model_workspace_id(const cbm_zova_publish_model_t *model) { - return model ? model->workspace_id : NULL; -} - -const cbm_zova_workspace_generation_input_t *cbm_zova_publish_model_input( - const cbm_zova_publish_model_t *model) { return model ? &model->input : NULL; } -int cbm_zova_publish_model_node_count(const cbm_zova_publish_model_t *model) { - return model ? model->node_count : 0; -} -const cbm_zova_publish_node_t *cbm_zova_publish_model_node_at( - const cbm_zova_publish_model_t *model, int index) { - return model && index >= 0 && index < model->node_count ? &model->nodes[index].value : NULL; -} -int cbm_zova_publish_model_set_node_source_ordinal( - cbm_zova_publish_model_t *model, int index, uint64_t source_ordinal) { - if (!model || index < 0 || index >= model->node_count || source_ordinal > INT64_MAX) return -1; - model->nodes[index].value.source_ordinal = source_ordinal; - return 0; -} -const char *cbm_zova_publish_model_fts_name_at( - const cbm_zova_publish_model_t *model, int index) { - return model && index >= 0 && index < model->node_count ? model->nodes[index].fts_name : NULL; -} -const char *cbm_zova_publish_model_stable_id_for_dump_id( - const cbm_zova_publish_model_t *model, int64_t dump_id) { - return model ? model_lookup(model, dump_id) : NULL; -} -int cbm_zova_publish_model_edge_count(const cbm_zova_publish_model_t *model) { - return model ? model->edge_count : 0; -} -const cbm_zova_publish_edge_t *cbm_zova_publish_model_edge_at( - const cbm_zova_publish_model_t *model, int index) { - return model && index >= 0 && index < model->edge_count ? &model->edges[index].value : NULL; -} -int cbm_zova_publish_model_topology_count(const cbm_zova_publish_model_t *model) { - return model ? model->topology_count : 0; -} -const cbm_zova_publish_edge_t *cbm_zova_publish_model_topology_at( - const cbm_zova_publish_model_t *model, int index) { - return model && index >= 0 && index < model->topology_count - ? &model->topology[index].value : NULL; -} -int cbm_zova_publish_model_file_hash_count(const cbm_zova_publish_model_t *model) { - return model ? model->hash_count : 0; -} -const cbm_zova_file_hash_input_t *cbm_zova_publish_model_file_hash_at( - const cbm_zova_publish_model_t *model, int index) { - return model && index >= 0 && index < model->hash_count ? model->hashes[index].source : NULL; -} -int cbm_zova_publish_model_node_vector_count(const cbm_zova_publish_model_t *model) { - return model ? model->node_vector_count : 0; -} -const cbm_zova_publish_node_vector_t *cbm_zova_publish_model_node_vector_at( - const cbm_zova_publish_model_t *model, int index) { - return model && index >= 0 && index < model->node_vector_count - ? &model->node_vectors[index].value : NULL; -} -int cbm_zova_publish_model_token_vector_count(const cbm_zova_publish_model_t *model) { - return model ? model->token_vector_count : 0; -} -const cbm_zova_publish_token_vector_t *cbm_zova_publish_model_token_vector_at( - const cbm_zova_publish_model_t *model, int index) { - return model && index >= 0 && index < model->token_vector_count - ? &model->token_vectors[index].value : NULL; -} -const cbm_zova_workspace_generation_result_t *cbm_zova_publish_model_digests( - const cbm_zova_publish_model_t *model) { - return model && !model->prepared_pending ? &model->digests : NULL; -} -void cbm_zova_publish_model_metrics(const cbm_zova_publish_model_t *model, - cbm_zova_publish_model_metrics_t *out_metrics) { - if (!out_metrics) return; - if (model) *out_metrics = model->metrics; - else memset(out_metrics, 0, sizeof(*out_metrics)); -} - -int cbm_zova_publish_model_reuse_vector_digests( - cbm_zova_publish_model_t *model, - const cbm_zova_workspace_generation_result_t *previous, - uint32_t components) { - const uint32_t vector_components = CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS | - CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS; - if (!model || !previous || components == 0 || (components & ~vector_components) != 0) - return -1; - if ((components & CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS) != 0) { - if (model->node_vector_count != 0 || previous->node_vectors == 0 || - strlen(previous->node_vector_sha256) != CBM_ZOVA_DIGEST_HEX_SIZE - 1) - return -1; - memcpy(model->digests.node_vector_sha256, previous->node_vector_sha256, - CBM_ZOVA_DIGEST_HEX_SIZE); - model->digests.node_vectors = previous->node_vectors; - model->digests.node_vector_rows = previous->node_vector_rows; - } - if ((components & CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS) != 0) { - if (model->token_vector_count != 0 || previous->token_vectors == 0 || - strlen(previous->token_vector_sha256) != CBM_ZOVA_DIGEST_HEX_SIZE - 1) - return -1; - memcpy(model->digests.token_vector_sha256, previous->token_vector_sha256, - CBM_ZOVA_DIGEST_HEX_SIZE); - model->digests.token_vectors = previous->token_vectors; - model->digests.token_vector_rows = previous->token_vector_rows; - } - return 0; -} diff --git a/src/zova/cbm_zova_publish_model.zig b/src/zova/cbm_zova_publish_model.zig new file mode 100644 index 00000000..d8f330f5 --- /dev/null +++ b/src/zova/cbm_zova_publish_model.zig @@ -0,0 +1,1309 @@ +//! cbm_zova_publish_model.zig — The normalized form of one workspace +//! generation: stable ids, rows sorted by id, topology payloads and the +//! digests of every component. +//! +//! Implements src/zova/cbm_zova_publish_model.h. A model borrows the dump +//! arrays of its input and owns only the id, FTS and payload storage. + +const std = @import("std"); +const c = @import("c"); +const interop = @import("../foundation/interop.zig"); + +const CStr = interop.CStr; +const max_usize = std.math.maxInt(usize); +const Input = c.cbm_zova_workspace_generation_input_t; +const Result = c.cbm_zova_workspace_generation_result_t; +const Metrics = c.cbm_zova_publish_model_metrics_t; +const PublishNode = c.cbm_zova_publish_node_t; +const PublishEdge = c.cbm_zova_publish_edge_t; +const PublishNodeVector = c.cbm_zova_publish_node_vector_t; +const PublishTokenVector = c.cbm_zova_publish_token_vector_t; +const FileHash = c.cbm_zova_file_hash_input_t; +const Summary = c.cbm_zova_project_summary_input_t; +const DumpNode = c.CBMDumpNode; +const DumpEdge = c.CBMDumpEdge; +const Sha = c.cbm_sha256_backend_ctx; + +const MODEL_ID_CAP = c.CBM_ZOVA_WORKSPACE_ID_MAX + 80; +const MODEL_PATH_CAP = 4096; +const MODEL_DISCRIMINATOR_CAP = 512; + +/// Every way a build fails: invalid input or a failed allocation. +const BuildError = error{BuildFailed}; + +const ModelNode = extern struct { + value: PublishNode, + fts_name: [*c]u8, +}; + +const IdMap = extern struct { + dump_id: i64, + stable_id: CStr, + ordinal: u64, +}; + +const DigestState = extern struct { + metadata: Sha, + fts: Sha, + topology: Sha, + nodes: Sha, + tokens: Sha, +}; + +const Model = extern struct { + workspace_id: [c.CBM_ZOVA_WORKSPACE_ID_MAX]u8, + input: Input, + nodes: [*c]ModelNode, + node_id_storage: [*c]u8, + node_fts_storage: [*c]u8, + node_count: c_int, + dense_ids: [*c]CStr, + dense_ordinals: [*c]u64, + id_map: [*c]IdMap, + edges: [*c]PublishEdge, + edge_id_storage: [*c]u8, + edge_count: c_int, + topology: [*c]PublishEdge, + topology_count: c_int, + edge_payload_storage: [*c]u8, + hashes: [*c][*c]const FileHash, + hash_count: c_int, + node_vectors: [*c]PublishNodeVector, + node_vector_count: c_int, + token_vectors: [*c]PublishTokenVector, + token_id_storage: [*c]u8, + token_vector_count: c_int, + digests: Result, + metrics: Metrics, + pending_digests: DigestState, + prepared_pending: bool, +}; + +/// Test hooks; atomic because index runs may build models concurrently. +var model_fail_allocation_at = std.atomic.Value(i64).init(-1); +var model_allocation_count = std.atomic.Value(i64).init(0); +var model_build_count = std.atomic.Value(u64).init(0); + +fn modelAllocationFails() bool { + const index = model_allocation_count.fetchAdd(1, .seq_cst); + return index == model_fail_allocation_at.load(.seq_cst); +} + +fn modelMalloc(comptime T: type, count: usize) [*c]T { + return if (modelAllocationFails()) null else interop.mallocN(T, count); +} + +fn modelCalloc(comptime T: type, count: usize) [*c]T { + return if (modelAllocationFails()) null else interop.callocN(T, count); +} + +pub export fn cbm_zova_publish_model_test_fail_allocation_at(index: i64) void { + model_fail_allocation_at.store(index, .seq_cst); + model_allocation_count.store(0, .seq_cst); +} + +pub export fn cbm_zova_publish_model_test_allocation_count() i64 { + return model_allocation_count.load(.seq_cst); +} + +pub export fn cbm_zova_publish_model_test_reset_build_count() void { + model_build_count.store(0, .seq_cst); +} + +pub export fn cbm_zova_publish_model_test_build_count() u64 { + return model_build_count.load(.seq_cst); +} + +fn modelNow() c.struct_timespec { + var now = std.mem.zeroes(c.struct_timespec); + _ = c.cbm_clock_gettime(c.CLOCK_MONOTONIC, &now); + return now; +} + +fn modelElapsedMs(started: c.struct_timespec, finished: c.struct_timespec) f64 { + return @as(f64, @floatFromInt(finished.tv_sec - started.tv_sec)) * 1000.0 + + @as(f64, @floatFromInt(finished.tv_nsec - started.tv_nsec)) / 1000000.0; +} + +/// Milliseconds since `phase_started`, which then moves to now. +fn modelLap(phase_started: *c.struct_timespec) f64 { + const finished = modelNow(); + defer phase_started.* = finished; + return modelElapsedMs(phase_started.*, finished); +} + +fn modelNonzero(values: [*c]const u8, length: c_int) bool { + if (values == null or length < 0) return false; + for (values[0..@intCast(length)]) |value| { + if (value != 0) return true; + } + return false; +} + +fn hexInto(out: []u8, bytes: []const u8) void { + const digits = "0123456789abcdef"; + for (bytes, 0..) |byte, i| { + out[i * 2] = digits[byte >> 4]; + out[i * 2 + 1] = digits[byte & 0x0f]; + } +} + +fn hashBytes(hash: *c.cbm_sha256_ctx, bytes: []const u8) void { + c.cbm_sha256_update(hash, bytes.ptr, bytes.len); +} + +/// "::". `out` holds at +/// least the id stride of modelIdStorageLayout. +fn modelFinishId(hash: *c.cbm_sha256_ctx, prefix: []const u8, workspace_id: CStr, out: [*]u8) BuildError!void { + var digest: [c.CBM_SHA256_DIGEST_LEN]u8 = undefined; + var hex: [32]u8 = undefined; + c.cbm_sha256_final(hash, &digest); + hexInto(&hex, digest[0..16]); + const written = interop.bprint(out[0..MODEL_ID_CAP], "{s}:{s}:{s}", .{ prefix, std.mem.span(workspace_id), &hex }); + if (written >= MODEL_ID_CAP) return error.BuildFailed; +} + +fn modelNodeId(workspace_id: CStr, maybe_node: ?*const DumpNode, out: [*]u8) BuildError!void { + const node = maybe_node orelse return error.BuildFailed; + if (node.label == null or node.name == null or node.qualified_name == null or node.file_path == null) + return error.BuildFailed; + const path = std.mem.span(node.file_path); + const qualified = std.mem.span(node.qualified_name); + if (path.len >= MODEL_PATH_CAP) return error.BuildFailed; + + var hash: c.cbm_sha256_ctx = undefined; + c.cbm_sha256_init(&hash); + hashBytes(&hash, std.mem.span(node.label)); + hashBytes(&hash, "\x00"); + // The path is hashed with '/' separators on every platform. + var rest = path; + while (rest.len > 0) { + const separator = std.mem.indexOfScalar(u8, rest, '\\') orelse { + hashBytes(&hash, rest); + break; + }; + hashBytes(&hash, rest[0..separator]); + hashBytes(&hash, "/"); + rest = rest[separator + 1 ..]; + } + hashBytes(&hash, "\x00"); + hashBytes(&hash, qualified); + hashBytes(&hash, "\x00"); + if (qualified.len > 0) { + const prefix = "named:"; + if (qualified.len > MODEL_DISCRIMINATOR_CAP - (prefix.len + 1)) return error.BuildFailed; + hashBytes(&hash, prefix); + hashBytes(&hash, qualified); + } else { + var discriminator: [MODEL_DISCRIMINATOR_CAP]u8 = undefined; + if (node.name[0] == 0 or node.start_line < 0 or node.end_line < node.start_line) + return error.BuildFailed; + const length = interop.bprint(&discriminator, "local:{s}:span:{d}:{d}", .{ std.mem.span(node.name), node.start_line, node.end_line }); + if (length >= discriminator.len) return error.BuildFailed; + hashBytes(&hash, discriminator[0..length]); + } + hashBytes(&hash, "\x00"); + return modelFinishId(&hash, "n:v2", workspace_id, out); +} + +fn modelHashId(prefix: []const u8, workspace_id: CStr, parts: []const CStr, out: [*]u8) BuildError!void { + var hash: c.cbm_sha256_ctx = undefined; + c.cbm_sha256_init(&hash); + for (parts) |part| { + if (part == null) return error.BuildFailed; + hashBytes(&hash, std.mem.span(part)); + hashBytes(&hash, "\x00"); + } + return modelFinishId(&hash, prefix, workspace_id, out); +} + +/// A NULL `local_name` reads as "". +fn modelEdgeId(workspace_id: CStr, source: CStr, edge_type: CStr, target: CStr, local_name: CStr, out: [*]u8) BuildError!void { + return modelHashId("e:v1", workspace_id, &.{ source, edge_type, target, if (local_name != null) local_name else "" }, out); +} + +fn modelTokenId(workspace_id: CStr, token: CStr, out: [*]u8) BuildError!void { + return modelHashId("t:v1", workspace_id, &.{token}, out); +} + +fn modelCamelBoundary(input: []const u8, index: usize) bool { + if (index == 0) return false; + const current = input[index]; + const previous = input[index - 1]; + const next: u8 = if (index + 1 < input.len) input[index + 1] else 0; + if (std.ascii.isUpper(current) and std.ascii.isLower(previous)) return true; + return std.ascii.isUpper(current) and std.ascii.isUpper(previous) and std.ascii.isLower(next); +} + +const CamelSize = struct { size: usize, length: usize }; + +/// Bytes modelCamelNameInto writes for `input`, and the length of `input`. +fn modelCamelStorageSize(input: CStr) ?CamelSize { + if (input == null) return null; + const name = std.mem.span(input); + var boundaries: usize = 0; + for (0..name.len) |i| { + if (modelCamelBoundary(name, i)) boundaries += 1; + } + if (name.len > (max_usize - 2) / 2 or boundaries > max_usize - (name.len * 2) - 2) return null; + return .{ .size = name.len * 2 + boundaries + 2, .length = name.len }; +} + +/// " ". +fn modelCamelNameInto(input: CStr, length: usize, value: [*c]u8, capacity: usize) BuildError!void { + if (input == null or value == null or length > (max_usize - 2) / 2 or capacity < length * 2 + 2) + return error.BuildFailed; + const name = input[0..length]; + @memcpy(value[0..length], name); + var output = length; + value[output] = ' '; + output += 1; + for (name, 0..) |char, i| { + if (modelCamelBoundary(name, i)) { + value[output] = ' '; + output += 1; + } + value[output] = char; + output += 1; + } + value[output] = 0; +} + +const IdStorageLayout = struct { stride: usize, bytes: usize }; + +fn modelIdStorageLayout(workspace_id: CStr, count: usize) ?IdStorageLayout { + if (workspace_id == null) return null; + const workspace_length = std.mem.span(workspace_id).len; + // n:v2::<32 hex>, e:v1:..., and t:v1:... are equal-width. + if (workspace_length > max_usize - 39) return null; + const stride = workspace_length + 39; + if (count > max_usize / stride or stride > MODEL_ID_CAP) return null; + return .{ .stride = stride, .bytes = count * stride }; +} + +// ── qsort comparators ────────────────────────────────────────────── + +fn cast(comptime T: type, ptr: ?*const anyopaque) *const T { + return @ptrCast(@alignCast(ptr.?)); +} + +fn compare(left: anytype, right: @TypeOf(left)) c_int { + return if (left < right) -1 else if (left > right) 1 else 0; +} + +fn modelIdCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + return compare(cast(IdMap, left_ptr).dump_id, cast(IdMap, right_ptr).dump_id); +} + +fn modelNodeCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + return c.strcmp(cast(ModelNode, left_ptr).value.stable_id, cast(ModelNode, right_ptr).value.stable_id); +} + +fn modelNullableCompare(left: CStr, right: CStr) c_int { + return c.strcmp(if (left != null) left else "", if (right != null) right else ""); +} + +fn modelEdgeCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + const left = cast(PublishEdge, left_ptr); + const right = cast(PublishEdge, right_ptr); + var comparison = c.strcmp(left.edge_id, right.edge_id); + if (comparison != 0) return comparison; + comparison = c.strcmp(left.source_stable_id, right.source_stable_id); + if (comparison != 0) return comparison; + comparison = modelNullableCompare(left.source.*.type, right.source.*.type); + if (comparison != 0) return comparison; + comparison = c.strcmp(left.target_stable_id, right.target_stable_id); + if (comparison != 0) return comparison; + comparison = modelNullableCompare(left.source.*.properties, right.source.*.properties); + if (comparison != 0) return comparison; + return modelNullableCompare(left.source.*.url_path, right.source.*.url_path); +} + +fn modelTopologyCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + const left = cast(PublishEdge, left_ptr); + const right = cast(PublishEdge, right_ptr); + var comparison = compare(left.source_ordinal, right.source_ordinal); + if (comparison != 0) return comparison; + comparison = c.strcmp(left.source.*.type, right.source.*.type); + if (comparison != 0) return comparison; + return compare(left.target_ordinal, right.target_ordinal); +} + +const TopologySeed = extern struct { + source: [*c]const DumpEdge, + source_ordinal: u64, + target_ordinal: u64, +}; + +fn preparedTopologySeedCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + const left = cast(TopologySeed, left_ptr); + const right = cast(TopologySeed, right_ptr); + if (left.source_ordinal != right.source_ordinal) return compare(left.source_ordinal, right.source_ordinal); + const left_type = left.source.*.type; + const right_type = right.source.*.type; + const comparison = if (left_type == right_type) 0 else c.strcmp(left_type, right_type); + if (comparison != 0) return comparison; + return compare(left.target_ordinal, right.target_ordinal); +} + +const GroupEdge = extern struct { + source: [*c]const DumpEdge, + edge_id: [MODEL_ID_CAP]u8, +}; + +const PayloadGroup = extern struct { + topology_index: usize, + edge_offset: usize, + edge_count: usize, + payload_size: usize, +}; + +fn preparedEdgePayloadIsDefault(edge: [*c]const DumpEdge) bool { + if (edge == null) return false; + const properties: CStr = if (edge.*.properties != null) edge.*.properties else "{}"; + const url_path: CStr = if (edge.*.url_path != null) edge.*.url_path else ""; + const local_name: CStr = if (edge.*.local_name != null) edge.*.local_name else ""; + return c.strcmp(properties, "{}") == 0 and url_path[0] == 0 and local_name[0] == 0; +} + +fn preparedGroupEdgeCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + return c.strcmp(&cast(GroupEdge, left_ptr).edge_id, &cast(GroupEdge, right_ptr).edge_id); +} + +fn modelHashCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + const left = cast([*c]const FileHash, left_ptr).*; + const right = cast([*c]const FileHash, right_ptr).*; + return c.strcmp(left.*.file_path, right.*.file_path); +} + +fn modelNodeVectorCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + return c.strcmp(cast(PublishNodeVector, left_ptr).stable_id, cast(PublishNodeVector, right_ptr).stable_id); +} + +fn modelTokenVectorCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + return c.strcmp(cast(PublishTokenVector, left_ptr).token_id, cast(PublishTokenVector, right_ptr).token_id); +} + +// ── Dump id lookups ──────────────────────────────────────────────── + +fn modelSparseLookup(model: *const Model, dump_id: i64) ?*IdMap { + var low: usize = 0; + var high: usize = @intCast(model.node_count); + while (low < high) { + const middle = low + (high - low) / 2; + const entry: *IdMap = model.id_map + middle; + if (entry.dump_id == dump_id) return entry; + if (entry.dump_id < dump_id) low = middle + 1 else high = middle; + } + return null; +} + +fn modelLookup(model: *const Model, dump_id: i64) CStr { + if (model.dense_ids != null) { + if (dump_id <= 0 or dump_id > model.node_count) return null; + return model.dense_ids[@intCast(dump_id - 1)]; + } + return if (modelSparseLookup(model, dump_id)) |entry| entry.stable_id else null; +} + +fn modelLookupOrdinal(model: *const Model, dump_id: i64) ?u64 { + if (model.dense_ids != null) { + if (dump_id <= 0 or dump_id > model.node_count or + model.dense_ids[@intCast(dump_id - 1)] == null or model.dense_ordinals == null) + return null; + return model.dense_ordinals[@intCast(dump_id - 1)]; + } + return if (modelSparseLookup(model, dump_id)) |entry| entry.ordinal else null; +} + +// ── Digests ──────────────────────────────────────────────────────── + +fn modelDigestText(context: *Sha, value: CStr) void { + const text: []const u8 = if (value != null) std.mem.span(value) else ""; + c.cbm_sha256_backend_update(context, text.ptr, text.len); + c.cbm_sha256_backend_update(context, "\x00", 1); +} + +fn modelDigestI64(context: *Sha, value: i64) void { + var bytes: [8]u8 = undefined; + std.mem.writeInt(i64, &bytes, value, .little); + c.cbm_sha256_backend_update(context, &bytes, bytes.len); +} + +fn modelDigestFinish(context: *Sha, output: *[c.CBM_ZOVA_DIGEST_HEX_SIZE]u8) void { + var digest: [c.CBM_SHA256_DIGEST_LEN]u8 = undefined; + c.cbm_sha256_backend_final(context, &digest); + hexInto(output[0..c.CBM_SHA256_HEX_LEN], &digest); + output[c.CBM_SHA256_HEX_LEN] = 0; +} + +fn modelDigestTopologyKey(context: *Sha, row: *const PublishEdge) void { + modelDigestText(context, row.source_stable_id); + modelDigestText(context, row.source.*.type); + modelDigestText(context, row.target_stable_id); +} + +fn modelDigestTopologyPayload(digests: *DigestState, row: *const PublishEdge) void { + modelDigestTopologyKey(&digests.metadata, row); + c.cbm_sha256_backend_update(&digests.metadata, row.payload, row.payload_len); +} + +fn modelDigestInit(digests: *DigestState) void { + c.cbm_sha256_backend_init(&digests.metadata); + c.cbm_sha256_backend_init(&digests.fts); + c.cbm_sha256_backend_init(&digests.topology); + c.cbm_sha256_backend_init(&digests.nodes); + c.cbm_sha256_backend_init(&digests.tokens); +} + +// ── Build ────────────────────────────────────────────────────────── + +fn empty(text: CStr) bool { + return text == null or text[0] == 0; +} + +fn summaryValid(summary: *const Summary) bool { + return !summary.present or (summary.summary != null and summary.source_hash != null and + summary.created_at != null and summary.updated_at != null); +} + +fn modelValidateInput(workspace_id: CStr, maybe_input: ?*const Input) BuildError!*const Input { + const input = maybe_input orelse return error.BuildFailed; + if (workspace_id == null or c.cbm_zova_workspace_id_validate(workspace_id) != 0 or + empty(input.root_path) or empty(input.project) or empty(input.indexed_at) or + empty(input.model_fingerprint) or input.vector_dimensions <= 0 or + input.node_count < 0 or input.edge_count < 0 or input.node_vector_count < 0 or + input.token_vector_count < 0 or input.file_hash_count < 0 or + (input.node_count != 0 and input.nodes == null) or + (input.edge_count != 0 and input.edges == null) or + (input.node_vector_count != 0 and input.node_vectors == null) or + (input.token_vector_count != 0 and input.token_vectors == null) or + (input.file_hash_count != 0 and input.file_hashes == null) or + !summaryValid(&input.project_summary)) + return error.BuildFailed; + return input; +} + +const MODEL_NODE_CHUNK_SIZE = 1024; + +const NodeBuildContext = struct { + model: *Model, + id_stride: usize, + fts_bytes: usize, + failed: [*]u8, +}; + +/// Stable ids and FTS names of one chunk of nodes. On entry a row's `ordinal` +/// holds the length of the node name. +fn modelBuildNodeChunk(chunk: c_int, opaque_context: ?*anyopaque) callconv(.c) void { + const context: *NodeBuildContext = @ptrCast(@alignCast(opaque_context.?)); + const model = context.model; + const nodes = model.nodes[0..@intCast(model.node_count)]; + const begin = @as(usize, @intCast(chunk)) * MODEL_NODE_CHUNK_SIZE; + const end = @min(begin + MODEL_NODE_CHUNK_SIZE, nodes.len); + for (nodes[begin..end], begin..) |*row, i| { + const node = row.value.source; + const stable_id = model.node_id_storage + i * context.id_stride; + const fts_end = if (i + 1 < nodes.len) nodes[i + 1].fts_name else model.node_fts_storage + context.fts_bytes; + const fts_capacity = @intFromPtr(fts_end) - @intFromPtr(row.fts_name); + const name_length: usize = @intCast(row.value.ordinal); + modelNodeId(&model.workspace_id, node, stable_id) catch { + context.failed[@intCast(chunk)] = 1; + return; + }; + modelCamelNameInto(node.*.name, name_length, row.fts_name, fts_capacity) catch { + context.failed[@intCast(chunk)] = 1; + return; + }; + row.value.stable_id = stable_id; + row.value.ordinal = 0; + } +} + +fn modelBuildNodes(model: *Model, digests: *DigestState) BuildError!void { + if (model.input.node_count == 0) return; + const count: usize = @intCast(model.input.node_count); + model.nodes = modelCalloc(ModelNode, count); + model.dense_ids = modelCalloc(CStr, count); + model.dense_ordinals = modelCalloc(u64, count); + if (model.nodes == null or model.dense_ids == null or model.dense_ordinals == null) + return error.BuildFailed; + const layout = modelIdStorageLayout(&model.workspace_id, count) orelse return error.BuildFailed; + const nodes = model.nodes[0..count]; + const input_nodes = model.input.nodes[0..count]; + // Until the rows are filled in, `ordinal` and `source_ordinal` hold the + // FTS size and the name length. + var fts_bytes: usize = 0; + for (nodes, input_nodes) |*row, *node| { + const camel = modelCamelStorageSize(node.name) orelse return error.BuildFailed; + if (camel.size > max_usize - fts_bytes) return error.BuildFailed; + row.value.ordinal = camel.size; + row.value.source_ordinal = camel.length; + fts_bytes += camel.size; + } + model.node_id_storage = modelMalloc(u8, layout.bytes); + model.node_fts_storage = modelMalloc(u8, fts_bytes); + if (model.node_id_storage == null or model.node_fts_storage == null) return error.BuildFailed; + model.node_count = model.input.node_count; + var dense_ids = true; + var fts_cursor: [*c]u8 = model.node_fts_storage; + for (nodes, input_nodes, 0..) |*row, *node, i| { + const stable_id = model.node_id_storage + i * layout.stride; + const fts_capacity: usize = @intCast(row.value.ordinal); + const name_length = row.value.source_ordinal; + if (node.id <= 0 or node.project == null or c.strcmp(node.project, model.input.project) != 0 or + node.label == null or node.name == null or node.qualified_name == null or node.file_path == null) + return error.BuildFailed; + row.value = .{ .source = node, .stable_id = stable_id, .ordinal = name_length, .source_ordinal = i }; + row.fts_name = fts_cursor; + fts_cursor += fts_capacity; + if (node.id > model.node_count or model.dense_ids[@intCast(node.id - 1)] != null) { + dense_ids = false; + } else { + model.dense_ids[@intCast(node.id - 1)] = stable_id; + } + } + { + const chunk_count = (count + MODEL_NODE_CHUNK_SIZE - 1) / MODEL_NODE_CHUNK_SIZE; + const failed = modelCalloc(u8, chunk_count); + if (failed == null) return error.BuildFailed; + defer c.free(failed); + var context = NodeBuildContext{ .model = model, .id_stride = layout.stride, .fts_bytes = fts_bytes, .failed = failed }; + c.cbm_parallel_for(@intCast(chunk_count), modelBuildNodeChunk, &context, .{}); + for (failed[0..chunk_count]) |chunk_failed| { + if (chunk_failed != 0) return error.BuildFailed; + } + } + model.metrics.stable_node_id_computations += count; + model.metrics.camel_split_computations += count; + if (!dense_ids) { + c.free(@ptrCast(model.dense_ids)); + model.dense_ids = null; + c.free(model.dense_ordinals); + model.dense_ordinals = null; + model.id_map = modelCalloc(IdMap, count); + if (model.id_map == null) return error.BuildFailed; + const id_map = model.id_map[0..count]; + for (id_map, nodes) |*entry, *row| { + entry.* = .{ .dump_id = row.value.source.*.id, .stable_id = row.value.stable_id, .ordinal = 0 }; + } + if (count > 1) c.qsort(id_map.ptr, count, @sizeOf(IdMap), modelIdCompare); + model.metrics.global_sorts += 1; + for (1..count) |i| { + if (id_map[i - 1].dump_id == id_map[i].dump_id) return error.BuildFailed; + } + } + if (count > 1) c.qsort(nodes.ptr, count, @sizeOf(ModelNode), modelNodeCompare); + model.metrics.global_sorts += 1; + for (nodes, 0..) |*row, i| { + if (i > 0 and c.strcmp(nodes[i - 1].value.stable_id, row.value.stable_id) == 0) + return error.BuildFailed; + row.value.ordinal = i; + const node: *const DumpNode = row.value.source; + if (model.dense_ids != null) { + model.dense_ordinals[@intCast(node.id - 1)] = i; + } else { + const entry = modelSparseLookup(model, node.id) orelse return error.BuildFailed; + entry.ordinal = i; + } + modelDigestText(&digests.metadata, row.value.stable_id); + modelDigestText(&digests.metadata, node.project); + modelDigestText(&digests.metadata, node.label); + modelDigestText(&digests.metadata, node.name); + modelDigestText(&digests.metadata, node.qualified_name); + modelDigestText(&digests.metadata, node.file_path); + modelDigestI64(&digests.metadata, node.start_line); + modelDigestI64(&digests.metadata, node.end_line); + modelDigestText(&digests.metadata, node.properties); + modelDigestText(&digests.fts, row.value.stable_id); + modelDigestText(&digests.fts, row.fts_name); + modelDigestText(&digests.fts, node.qualified_name); + modelDigestText(&digests.fts, node.label); + modelDigestText(&digests.fts, node.file_path); + } +} + +fn modelBuildEdges(model: *Model, digests: *DigestState) BuildError!void { + if (model.input.edge_count == 0) return; + const count: usize = @intCast(model.input.edge_count); + model.edges = modelCalloc(PublishEdge, count); + const layout = modelIdStorageLayout(&model.workspace_id, count) orelse return error.BuildFailed; + model.edge_id_storage = modelMalloc(u8, layout.bytes); + if (model.edges == null or model.edge_id_storage == null) return error.BuildFailed; + model.edge_count = model.input.edge_count; + const edges = model.edges[0..count]; + for (edges, model.input.edges[0..count], 0..) |*row, *edge, i| { + const source = modelLookup(model, edge.source_id); + const target = modelLookup(model, edge.target_id); + model.metrics.endpoint_lookups += 2; + if (edge.project == null or c.strcmp(edge.project, model.input.project) != 0 or + empty(edge.type) or source == null or target == null) return error.BuildFailed; + const source_ordinal = modelLookupOrdinal(model, edge.source_id) orelse return error.BuildFailed; + const target_ordinal = modelLookupOrdinal(model, edge.target_id) orelse return error.BuildFailed; + const edge_id = model.edge_id_storage + i * layout.stride; + try modelEdgeId(&model.workspace_id, source, edge.type, target, edge.local_name, edge_id); + row.* = .{ + .source = edge, + .edge_id = edge_id, + .source_stable_id = source, + .target_stable_id = target, + .source_ordinal = source_ordinal, + .target_ordinal = target_ordinal, + }; + model.metrics.edge_id_computations += 1; + } + if (count > 1) c.qsort(edges.ptr, count, @sizeOf(PublishEdge), modelEdgeCompare); + model.metrics.global_sorts += 1; + // Rows with the same edge id collapse into one; the last one wins. + var unique_edges: usize = 0; + for (0..count) |i| { + const current = edges[i]; + if (unique_edges > 0 and c.strcmp(edges[unique_edges - 1].edge_id, current.edge_id) == 0) { + const previous = &edges[unique_edges - 1]; + if (c.strcmp(previous.source_stable_id, current.source_stable_id) != 0 or + c.strcmp(previous.source.*.type, current.source.*.type) != 0 or + c.strcmp(previous.target_stable_id, current.target_stable_id) != 0 or + modelNullableCompare(previous.source.*.local_name, current.source.*.local_name) != 0) + return error.BuildFailed; + previous.* = current; + } else { + if (unique_edges != i) edges[unique_edges] = current; + unique_edges += 1; + } + } + model.edge_count = @intCast(unique_edges); + const unique = edges[0..unique_edges]; + for (unique, 0..) |*row, i| row.ordinal = i; + + model.topology = modelCalloc(PublishEdge, unique_edges); + if (model.topology == null) return error.BuildFailed; + const topology = model.topology[0..unique_edges]; + @memcpy(topology, unique); + if (unique_edges > 1) c.qsort(topology.ptr, unique_edges, @sizeOf(PublishEdge), modelTopologyCompare); + model.metrics.global_sorts += 1; + var unique_topology: usize = 0; + for (0..unique_edges) |i| { + const current = topology[i]; + if (unique_topology == 0 or modelTopologyCompare(&topology[unique_topology - 1], ¤t) != 0) { + const row = &topology[unique_topology]; + if (unique_topology != i) row.* = current; + row.ordinal = unique_topology; + modelDigestTopologyKey(&digests.topology, row); + unique_topology += 1; + } + if (current.ordinal >= unique_edges) return error.BuildFailed; + unique[@intCast(current.ordinal)].topology_ordinal = unique_topology - 1; + } + model.topology_count = @intCast(unique_topology); + + // One allocation: the edge count, first edge and write cursor of each + // topology row. + const counts_storage = modelCalloc(usize, unique_topology * 3); + defer c.free(counts_storage); + const grouped_storage = modelMalloc([*c]const DumpEdge, unique_edges); + defer c.free(@ptrCast(grouped_storage)); + if (counts_storage == null or grouped_storage == null) return error.BuildFailed; + const counts = counts_storage[0..unique_topology]; + const offsets = counts_storage[unique_topology..][0..unique_topology]; + const cursors = counts_storage[unique_topology * 2 ..][0..unique_topology]; + const grouped = grouped_storage[0..unique_edges]; + for (unique) |*row| { + if (row.topology_ordinal >= unique_topology) return error.BuildFailed; + counts[@intCast(row.topology_ordinal)] += 1; + } + var grouped_count: usize = 0; + for (counts, offsets, cursors) |edge_count, *offset, *cursor| { + offset.* = grouped_count; + cursor.* = grouped_count; + if (edge_count > max_usize - grouped_count) return error.BuildFailed; + grouped_count += edge_count; + } + if (grouped_count != unique_edges) return error.BuildFailed; + for (unique) |*row| { + const cursor = &cursors[@intCast(row.topology_ordinal)]; + grouped[cursor.*] = row.source; + cursor.* += 1; + } + + // The cursors are done; from here they hold each row's payload size. + var payload_bytes: usize = 0; + for (counts, offsets, cursors) |edge_count, offset, *payload_size| { + payload_size.* = 0; + if (c.cbm_zova_edge_payload_encoded_size(grouped.ptr + offset, edge_count, payload_size) != 0 or + payload_size.* > max_usize - payload_bytes) return error.BuildFailed; + payload_bytes += payload_size.*; + } + if (payload_bytes > 0) { + model.edge_payload_storage = modelMalloc(u8, payload_bytes); + if (model.edge_payload_storage == null) return error.BuildFailed; + } + var payload_cursor: [*c]u8 = model.edge_payload_storage; + for (topology[0..unique_topology], counts, offsets, cursors) |*row, edge_count, offset, payload_size| { + row.logical_edge_count = edge_count; + row.payload = if (payload_size > 0) payload_cursor else null; + row.payload_len = payload_size; + var encoded: usize = 0; + if (c.cbm_zova_edge_payload_encode(grouped.ptr + offset, edge_count, payload_cursor, payload_size, &encoded) != 0 or + encoded != payload_size) return error.BuildFailed; + if (encoded > 0) payload_cursor += encoded; + modelDigestTopologyPayload(digests, row); + } +} + +/// End of the run of seeds that share the topology key of seeds[begin]. +fn preparedGroupEnd(seeds: []const TopologySeed, begin: usize) usize { + var end = begin + 1; + while (end < seeds.len and preparedTopologySeedCompare(&seeds[begin], &seeds[end]) == 0) end += 1; + return end; +} + +/// Build only the topology rows required by authoritative full publication. +/// Finalized graph-buffer input already has dense node IDs and deduplicated +/// logical edges, so delta-only logical edge IDs and edge ordering are omitted. +fn modelBuildPreparedTopology(model: *Model, digests: *DigestState) BuildError!void { + model.edge_count = model.input.edge_count; + if (model.input.edge_count == 0) return; + if (model.dense_ids == null or model.dense_ordinals == null) return error.BuildFailed; + const count: usize = @intCast(model.input.edge_count); + var phase_started = modelNow(); + model.topology = modelCalloc(PublishEdge, count); + var seeds_storage = modelMalloc(TopologySeed, count); + var payload_groups: [*c]PayloadGroup = null; + var payload_edges: [*c][*c]const DumpEdge = null; + var ordered: [*c]GroupEdge = null; + defer { + c.free(ordered); + c.free(@ptrCast(payload_edges)); + c.free(payload_groups); + c.free(seeds_storage); + } + if (model.topology == null or seeds_storage == null) return error.BuildFailed; + const topology = model.topology[0..count]; + const seeds = seeds_storage[0..count]; + for (seeds, model.input.edges[0..count]) |*seed, *edge| { + model.metrics.endpoint_lookups += 2; + if (edge.source_id <= 0 or edge.source_id > model.node_count or + edge.target_id <= 0 or edge.target_id > model.node_count) return error.BuildFailed; + const source_index: usize = @intCast(edge.source_id - 1); + const target_index: usize = @intCast(edge.target_id - 1); + if (edge.project == null or + (edge.project != model.input.project and c.strcmp(edge.project, model.input.project) != 0) or + empty(edge.type) or model.dense_ids[source_index] == null or model.dense_ids[target_index] == null) + return error.BuildFailed; + seed.* = .{ + .source = edge, + .source_ordinal = model.dense_ordinals[source_index], + .target_ordinal = model.dense_ordinals[target_index], + }; + } + model.metrics.prepared_endpoint_ms = modelLap(&phase_started); + if (count > 1) c.qsort(seeds.ptr, count, @sizeOf(TopologySeed), preparedTopologySeedCompare); + model.metrics.global_sorts += 1; + model.metrics.prepared_topology_sort_ms = modelLap(&phase_started); + + // One topology row per run of equal seeds. A row with one edge gets its + // payload size here; rows with several edges are sized in the next pass. + var unique_topology: usize = 0; + var payload_group_count: usize = 0; + var payload_edge_count: usize = 0; + var payload_bytes: usize = 0; + var i: usize = 0; + while (i < count) { + const end = preparedGroupEnd(seeds, i); + const edge = seeds[i].source; + const group_edge_count = end - i; + const row = &topology[unique_topology]; + row.* = .{ + .source = edge, + .source_stable_id = model.dense_ids[@intCast(edge.*.source_id - 1)], + .target_stable_id = model.dense_ids[@intCast(edge.*.target_id - 1)], + .source_ordinal = seeds[i].source_ordinal, + .target_ordinal = seeds[i].target_ordinal, + .ordinal = unique_topology, + .logical_edge_count = group_edge_count, + }; + if (group_edge_count == 1 and preparedEdgePayloadIsDefault(edge)) { + model.metrics.prepared_single_default_payload_count += 1; + } else if (group_edge_count == 1) { + if (c.cbm_zova_edge_payload_encoded_size(&edge, 1, &row.payload_len) != 0 or + row.payload_len > max_usize - payload_bytes) return error.BuildFailed; + payload_bytes += row.payload_len; + } else { + payload_group_count += 1; + if (group_edge_count > max_usize - payload_edge_count) return error.BuildFailed; + payload_edge_count += group_edge_count; + } + unique_topology += 1; + i = end; + } + model.topology_count = @intCast(unique_topology); + model.metrics.prepared_payload_scratch_edge_count = payload_edge_count; + model.metrics.prepared_topology_group_ms = modelLap(&phase_started); + + if (payload_group_count > 0) { + payload_groups = modelCalloc(PayloadGroup, payload_group_count); + payload_edges = modelMalloc([*c]const DumpEdge, payload_edge_count); + if (payload_groups == null or payload_edges == null) return error.BuildFailed; + } + // The edges of a multi-edge row are encoded in logical edge id order. + var payload_group_index: usize = 0; + var payload_edge_offset: usize = 0; + var topology_index: usize = 0; + i = 0; + while (i < count) : (topology_index += 1) { + const end = preparedGroupEnd(seeds, i); + const group_edge_count = end - i; + if (group_edge_count > 1) { + const group: *PayloadGroup = payload_groups + payload_group_index; + payload_group_index += 1; + group.* = .{ + .topology_index = topology_index, + .edge_offset = payload_edge_offset, + .edge_count = group_edge_count, + .payload_size = 0, + }; + const group_edges = payload_edges[payload_edge_offset..][0..group_edge_count]; + for (group_edges, seeds[i..end]) |*slot, *seed| slot.* = seed.source; + ordered = modelMalloc(GroupEdge, group_edge_count); + if (ordered == null) return error.BuildFailed; + const ordered_edges = ordered[0..group_edge_count]; + const row = &topology[topology_index]; + for (ordered_edges, group_edges) |*entry, source| { + entry.source = source; + try modelEdgeId(&model.workspace_id, row.source_stable_id, row.source.*.type, row.target_stable_id, source.*.local_name, &entry.edge_id); + model.metrics.edge_id_computations += 1; + } + c.qsort(ordered_edges.ptr, group_edge_count, @sizeOf(GroupEdge), preparedGroupEdgeCompare); + for (ordered_edges, group_edges, 0..) |*entry, *slot, j| { + if (j > 0 and c.strcmp(&ordered_edges[j - 1].edge_id, &entry.edge_id) == 0) + return error.BuildFailed; + slot.* = entry.source; + } + c.free(ordered); + ordered = null; + if (c.cbm_zova_edge_payload_encoded_size(group_edges.ptr, group_edge_count, &group.payload_size) != 0 or + group.payload_size > max_usize - payload_bytes) return error.BuildFailed; + row.payload_len = group.payload_size; + payload_bytes += group.payload_size; + payload_edge_offset += group_edge_count; + } + i = end; + } + if (payload_group_index != payload_group_count or payload_edge_offset != payload_edge_count) + return error.BuildFailed; + if (payload_bytes > 0) { + model.edge_payload_storage = modelMalloc(u8, payload_bytes); + if (model.edge_payload_storage == null) return error.BuildFailed; + } + var payload_cursor: [*c]u8 = model.edge_payload_storage; + var duplicate_group_index: usize = 0; + for (topology[0..unique_topology], 0..) |*row, row_index| { + if (row.payload_len == 0) continue; + const single_edge = row.source; + var edges: [*c]const [*c]const DumpEdge = &single_edge; + var edge_count: usize = 1; + if (row.logical_edge_count > 1) { + const group: *const PayloadGroup = payload_groups + duplicate_group_index; + duplicate_group_index += 1; + if (group.topology_index != row_index or group.payload_size != row.payload_len) + return error.BuildFailed; + edges = payload_edges + group.edge_offset; + edge_count = group.edge_count; + } + row.payload = payload_cursor; + var encoded: usize = 0; + if (c.cbm_zova_edge_payload_encode(edges, edge_count, payload_cursor, row.payload_len, &encoded) != 0 or + encoded != row.payload_len) return error.BuildFailed; + if (encoded > 0) payload_cursor += encoded; + } + if (duplicate_group_index != payload_group_count) return error.BuildFailed; + c.free(@ptrCast(payload_edges)); + payload_edges = null; + c.free(payload_groups); + payload_groups = null; + c.free(seeds_storage); + seeds_storage = null; + model.metrics.prepared_payload_ms = modelLap(&phase_started); + + for (topology[0..unique_topology]) |*row| { + modelDigestTopologyKey(&digests.topology, row); + modelDigestTopologyPayload(digests, row); + } + model.metrics.prepared_topology_digest_ms = modelLap(&phase_started); +} + +fn modelBuildHashes(model: *Model, digests: *DigestState) BuildError!void { + if (model.input.file_hash_count == 0) return; + const count: usize = @intCast(model.input.file_hash_count); + model.hashes = modelCalloc([*c]const FileHash, count); + if (model.hashes == null) return error.BuildFailed; + model.hash_count = model.input.file_hash_count; + const hashes = model.hashes[0..count]; + for (hashes, model.input.file_hashes[0..count]) |*slot, *hash| { + if (hash.file_path == null or hash.content_hash == null) return error.BuildFailed; + slot.* = hash; + } + if (count > 1) c.qsort(@ptrCast(hashes.ptr), count, @sizeOf([*c]const FileHash), modelHashCompare); + model.metrics.global_sorts += 1; + for (hashes, 0..) |hash, i| { + if (i > 0 and c.strcmp(hashes[i - 1].*.file_path, hash.*.file_path) == 0) return error.BuildFailed; + modelDigestText(&digests.metadata, hash.*.file_path); + modelDigestText(&digests.metadata, hash.*.content_hash); + modelDigestI64(&digests.metadata, hash.*.mtime_ns); + modelDigestI64(&digests.metadata, hash.*.size_bytes); + } +} + +fn modelBuildVectors(model: *Model, digests: *DigestState) BuildError!void { + const node_capacity: usize = @intCast(model.input.node_vector_count); + const token_capacity: usize = @intCast(model.input.token_vector_count); + if (node_capacity > 0) { + model.node_vectors = modelCalloc(PublishNodeVector, node_capacity); + if (model.node_vectors == null) return error.BuildFailed; + } + for (0..node_capacity) |i| { + const vector: *const c.CBMDumpVector = model.input.node_vectors + i; + const stable_id = modelLookup(model, vector.node_id); + if (vector.project == null or c.strcmp(vector.project, model.input.project) != 0 or stable_id == null) + return error.BuildFailed; + const node_ordinal = modelLookupOrdinal(model, vector.node_id) orelse return error.BuildFailed; + if (vector.vector == null or vector.vector_len != model.input.vector_dimensions) return error.BuildFailed; + if (!modelNonzero(vector.vector, vector.vector_len)) continue; + model.node_vectors[@intCast(model.node_vector_count)] = .{ .source = vector, .stable_id = stable_id, .node_ordinal = node_ordinal }; + model.node_vector_count += 1; + } + if (model.node_vector_count > 0) { + const rows = model.node_vectors[0..@intCast(model.node_vector_count)]; + if (rows.len > 1) c.qsort(rows.ptr, rows.len, @sizeOf(PublishNodeVector), modelNodeVectorCompare); + model.metrics.global_sorts += 1; + for (rows, 0..) |*row, i| { + if (i > 0 and c.strcmp(rows[i - 1].stable_id, row.stable_id) == 0) return error.BuildFailed; + row.ordinal = i; + modelDigestText(&digests.nodes, row.stable_id); + c.cbm_sha256_backend_update(&digests.nodes, row.source.*.vector, @intCast(row.source.*.vector_len)); + } + } + var token_id_stride: usize = 0; + if (token_capacity > 0) { + model.token_vectors = modelCalloc(PublishTokenVector, token_capacity); + const layout = modelIdStorageLayout(&model.workspace_id, token_capacity) orelse return error.BuildFailed; + token_id_stride = layout.stride; + model.token_id_storage = modelMalloc(u8, layout.bytes); + if (model.token_vectors == null or model.token_id_storage == null) return error.BuildFailed; + } + for (0..token_capacity) |i| { + const vector: *const c.CBMDumpTokenVec = model.input.token_vectors + i; + if (vector.id <= 0 or vector.project == null or + c.strcmp(vector.project, model.input.project) != 0 or vector.token == null or + vector.vector == null or vector.vector_len != model.input.vector_dimensions or + !std.math.isFinite(vector.idf)) return error.BuildFailed; + const token_id = model.token_id_storage + i * token_id_stride; + try modelTokenId(&model.workspace_id, vector.token, token_id); + model.metrics.token_id_computations += 1; + if (!modelNonzero(vector.vector, vector.vector_len)) continue; + model.token_vectors[@intCast(model.token_vector_count)] = .{ .source = vector, .token_id = token_id }; + model.token_vector_count += 1; + } + if (model.token_vector_count > 0) { + const rows = model.token_vectors[0..@intCast(model.token_vector_count)]; + if (rows.len > 1) c.qsort(rows.ptr, rows.len, @sizeOf(PublishTokenVector), modelTokenVectorCompare); + model.metrics.global_sorts += 1; + for (rows, 0..) |*row, i| { + if (i > 0 and c.strcmp(rows[i - 1].token_id, row.token_id) == 0) return error.BuildFailed; + row.ordinal = i; + const source: *const c.CBMDumpTokenVec = row.source; + modelDigestText(&digests.tokens, row.token_id); + modelDigestText(&digests.tokens, source.token); + c.cbm_sha256_backend_update(&digests.tokens, &source.idf, @sizeOf(f32)); + c.cbm_sha256_backend_update(&digests.tokens, source.vector, @intCast(source.vector_len)); + } + } +} + +fn modelDigestSummary(model: *Model, digests: *DigestState) void { + const summary = &model.input.project_summary; + modelDigestText(&digests.metadata, if (summary.present) "1" else "0"); + if (summary.present) { + modelDigestText(&digests.metadata, summary.summary); + modelDigestText(&digests.metadata, summary.source_hash); + modelDigestText(&digests.metadata, summary.created_at); + modelDigestText(&digests.metadata, summary.updated_at); + } +} + +fn modelDigestFinishAll(model: *Model, digests: *DigestState) void { + modelDigestFinish(&digests.metadata, &model.digests.metadata_sha256); + modelDigestFinish(&digests.fts, &model.digests.fts_sha256); + modelDigestFinish(&digests.topology, &model.digests.topology_sha256); + modelDigestFinish(&digests.nodes, &model.digests.node_vector_sha256); + modelDigestFinish(&digests.tokens, &model.digests.token_vector_sha256); + model.digests.graph_nodes = @intCast(model.node_count); + model.digests.graph_edges = @intCast(model.topology_count); + model.digests.metadata_nodes = @intCast(model.node_count); + model.digests.metadata_edges = @intCast(model.edge_count); + model.digests.metadata_topology_edges = @intCast(model.topology_count); + model.digests.fts_rows = @intCast(model.node_count); + model.digests.node_vectors = @intCast(model.node_vector_count); + model.digests.node_vector_rows = @intCast(model.node_vector_count); + model.digests.token_vectors = @intCast(model.token_vector_count); + model.digests.token_vector_rows = @intCast(model.token_vector_count); +} + +/// A zeroed model that holds the workspace id and a copy of `input`. +fn modelCreate(workspace_id: CStr, input: *const Input) BuildError!*Model { + model_allocation_count.store(0, .seq_cst); + const model = @as(?*Model, modelCalloc(Model, 1)) orelse return error.BuildFailed; + _ = interop.bprint(&model.workspace_id, "{s}", .{std.mem.span(workspace_id)}); + model.input = input.*; + return model; +} + +fn publishModelBuild(workspace_id: CStr, maybe_input: ?*const Input) BuildError!*Model { + const started = modelNow(); + const input = try modelValidateInput(workspace_id, maybe_input); + const model = try modelCreate(workspace_id, input); + errdefer cbm_zova_publish_model_free(model); + var digests: DigestState = undefined; + modelDigestInit(&digests); + var phase_started = modelNow(); + try modelBuildNodes(model, &digests); + model.metrics.nodes_ms = modelLap(&phase_started); + try modelBuildEdges(model, &digests); + model.metrics.edges_ms = modelLap(&phase_started); + try modelBuildHashes(model, &digests); + model.metrics.hashes_ms = modelLap(&phase_started); + modelDigestSummary(model, &digests); + try modelBuildVectors(model, &digests); + model.metrics.vectors_ms = modelLap(&phase_started); + modelDigestFinishAll(model, &digests); + model.metrics.digests_ms = modelLap(&phase_started); + model.metrics.normalization_ms = modelElapsedMs(started, phase_started); + _ = model_build_count.fetchAdd(1, .seq_cst); + return model; +} + +pub export fn cbm_zova_publish_model_build(workspace_id: CStr, input: ?*const Input, out_model: ?*?*Model) c_int { + const out = out_model orelse return -1; + out.* = null; + out.* = publishModelBuild(workspace_id, input) catch return -1; + return 0; +} + +fn preparedViewBuildBase(workspace_id: CStr, maybe_input: ?*const Input) BuildError!*Model { + const started = modelNow(); + const input = try modelValidateInput(workspace_id, maybe_input); + if (input.file_hash_count != 0 or input.file_hashes != null or input.project_summary.present) + return error.BuildFailed; + const view = try modelCreate(workspace_id, input); + errdefer cbm_zova_publish_model_free(view); + const digests = &view.pending_digests; + modelDigestInit(digests); + var phase_started = modelNow(); + try modelBuildNodes(view, digests); + if (view.node_count > 0 and (view.dense_ids == null or view.dense_ordinals == null)) + return error.BuildFailed; + view.metrics.nodes_ms = modelLap(&phase_started); + try modelBuildPreparedTopology(view, digests); + view.metrics.edges_ms = modelLap(&phase_started); + try modelBuildVectors(view, digests); + view.metrics.vectors_ms = modelLap(&phase_started); + view.metrics.normalization_ms = modelElapsedMs(started, phase_started); + view.prepared_pending = true; + return view; +} + +pub export fn cbm_zova_prepared_view_build_base(workspace_id: CStr, input: ?*const Input, out_view: ?*?*Model) c_int { + const out = out_view orelse return -1; + out.* = null; + out.* = preparedViewBuildBase(workspace_id, input) catch return -1; + return 0; +} + +pub export fn cbm_zova_prepared_view_complete(maybe_view: ?*Model, file_hashes: [*c]const FileHash, file_hash_count: c_int, project_summary: ?*const Summary) c_int { + const view = maybe_view orelse return -1; + if (!view.prepared_pending or file_hash_count < 0 or (file_hash_count > 0 and file_hashes == null)) + return -1; + const summary: Summary = if (project_summary) |given| given.* else .{}; + if (!summaryValid(&summary)) return -1; + view.input.file_hashes = file_hashes; + view.input.file_hash_count = file_hash_count; + view.input.project_summary = summary; + const started = modelNow(); + var phase_started = started; + modelBuildHashes(view, &view.pending_digests) catch return -1; + view.metrics.hashes_ms = modelLap(&phase_started); + modelDigestSummary(view, &view.pending_digests); + modelDigestFinishAll(view, &view.pending_digests); + view.metrics.digests_ms = modelLap(&phase_started); + view.metrics.normalization_ms += modelElapsedMs(started, phase_started); + view.prepared_pending = false; + return 0; +} + +pub export fn cbm_zova_prepared_view_build(workspace_id: CStr, maybe_input: ?*const Input, out_view: ?*?*Model) c_int { + const input = maybe_input orelse return -1; + const out = out_view orelse return -1; + out.* = null; + var base = input.*; + base.file_hashes = null; + base.file_hash_count = 0; + base.project_summary = .{}; + var view: ?*Model = null; + if (cbm_zova_prepared_view_build_base(workspace_id, &base, &view) != 0) return -1; + if (cbm_zova_prepared_view_complete(view, input.file_hashes, input.file_hash_count, &input.project_summary) != 0) { + cbm_zova_prepared_view_free(view); + return -1; + } + out.* = view; + return 0; +} + +pub export fn cbm_zova_prepared_view_free(view: ?*Model) void { + cbm_zova_publish_model_free(view); +} + +pub export fn cbm_zova_publish_model_free(maybe_model: ?*Model) void { + const model = maybe_model orelse return; + c.free(model.nodes); + c.free(model.node_id_storage); + c.free(model.node_fts_storage); + c.free(@ptrCast(model.dense_ids)); + c.free(model.dense_ordinals); + c.free(model.id_map); + c.free(model.edges); + c.free(model.edge_id_storage); + c.free(model.topology); + c.free(model.edge_payload_storage); + c.free(@ptrCast(model.hashes)); + c.free(model.node_vectors); + c.free(model.token_vectors); + c.free(model.token_id_storage); + c.free(model); +} + +// ── Accessors ────────────────────────────────────────────────────── + +/// rows[index] when `index` is inside `count` rows. +fn rowAt(comptime T: type, rows: [*c]T, count: c_int, index: c_int) ?*const T { + if (index < 0 or index >= count) return null; + return rows + @as(usize, @intCast(index)); +} + +pub export fn cbm_zova_publish_model_workspace_id(model: ?*const Model) CStr { + return if (model) |m| &m.workspace_id else null; +} + +pub export fn cbm_zova_publish_model_input(model: ?*const Model) ?*const Input { + return if (model) |m| &m.input else null; +} + +pub export fn cbm_zova_publish_model_node_count(model: ?*const Model) c_int { + return if (model) |m| m.node_count else 0; +} + +pub export fn cbm_zova_publish_model_node_at(model: ?*const Model, index: c_int) ?*const PublishNode { + const m = model orelse return null; + const row = rowAt(ModelNode, m.nodes, m.node_count, index) orelse return null; + return &row.value; +} + +pub export fn cbm_zova_publish_model_set_node_source_ordinal(model: ?*Model, index: c_int, source_ordinal: u64) c_int { + const m = model orelse return -1; + if (index < 0 or index >= m.node_count or source_ordinal > std.math.maxInt(i64)) return -1; + m.nodes[@intCast(index)].value.source_ordinal = source_ordinal; + return 0; +} + +pub export fn cbm_zova_publish_model_fts_name_at(model: ?*const Model, index: c_int) CStr { + const m = model orelse return null; + const row = rowAt(ModelNode, m.nodes, m.node_count, index) orelse return null; + return row.fts_name; +} + +pub export fn cbm_zova_publish_model_stable_id_for_dump_id(model: ?*const Model, dump_id: i64) CStr { + return if (model) |m| modelLookup(m, dump_id) else null; +} + +pub export fn cbm_zova_publish_model_edge_count(model: ?*const Model) c_int { + return if (model) |m| m.edge_count else 0; +} + +pub export fn cbm_zova_publish_model_edge_at(model: ?*const Model, index: c_int) ?*const PublishEdge { + const m = model orelse return null; + return rowAt(PublishEdge, m.edges, m.edge_count, index); +} + +pub export fn cbm_zova_publish_model_topology_count(model: ?*const Model) c_int { + return if (model) |m| m.topology_count else 0; +} + +pub export fn cbm_zova_publish_model_topology_at(model: ?*const Model, index: c_int) ?*const PublishEdge { + const m = model orelse return null; + return rowAt(PublishEdge, m.topology, m.topology_count, index); +} + +pub export fn cbm_zova_publish_model_file_hash_count(model: ?*const Model) c_int { + return if (model) |m| m.hash_count else 0; +} + +pub export fn cbm_zova_publish_model_file_hash_at(model: ?*const Model, index: c_int) ?*const FileHash { + const m = model orelse return null; + const slot = rowAt([*c]const FileHash, m.hashes, m.hash_count, index) orelse return null; + return slot.*; +} + +pub export fn cbm_zova_publish_model_node_vector_count(model: ?*const Model) c_int { + return if (model) |m| m.node_vector_count else 0; +} + +pub export fn cbm_zova_publish_model_node_vector_at(model: ?*const Model, index: c_int) ?*const PublishNodeVector { + const m = model orelse return null; + return rowAt(PublishNodeVector, m.node_vectors, m.node_vector_count, index); +} + +pub export fn cbm_zova_publish_model_token_vector_count(model: ?*const Model) c_int { + return if (model) |m| m.token_vector_count else 0; +} + +pub export fn cbm_zova_publish_model_token_vector_at(model: ?*const Model, index: c_int) ?*const PublishTokenVector { + const m = model orelse return null; + return rowAt(PublishTokenVector, m.token_vectors, m.token_vector_count, index); +} + +/// NULL until a prepared view is completed. +pub export fn cbm_zova_publish_model_digests(model: ?*const Model) ?*const Result { + const m = model orelse return null; + return if (m.prepared_pending) null else &m.digests; +} + +pub export fn cbm_zova_publish_model_metrics(model: ?*const Model, out_metrics: ?*Metrics) void { + const out = out_metrics orelse return; + out.* = if (model) |m| m.metrics else std.mem.zeroes(Metrics); +} + +fn digestComplete(hex: *const [c.CBM_ZOVA_DIGEST_HEX_SIZE]u8) bool { + return std.mem.sliceTo(hex, 0).len == c.CBM_ZOVA_DIGEST_HEX_SIZE - 1; +} + +pub export fn cbm_zova_publish_model_reuse_vector_digests(maybe_model: ?*Model, maybe_previous: ?*const Result, components: u32) c_int { + const NODE_VECTORS: u32 = c.CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS; + const TOKEN_VECTORS: u32 = c.CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS; + const model = maybe_model orelse return -1; + const previous = maybe_previous orelse return -1; + if (components == 0 or (components & ~(NODE_VECTORS | TOKEN_VECTORS)) != 0) return -1; + if ((components & NODE_VECTORS) != 0) { + if (model.node_vector_count != 0 or previous.node_vectors == 0 or + !digestComplete(&previous.node_vector_sha256)) return -1; + model.digests.node_vector_sha256 = previous.node_vector_sha256; + model.digests.node_vectors = previous.node_vectors; + model.digests.node_vector_rows = previous.node_vector_rows; + } + if ((components & TOKEN_VECTORS) != 0) { + if (model.token_vector_count != 0 or previous.token_vectors == 0 or + !digestComplete(&previous.token_vector_sha256)) return -1; + model.digests.token_vector_sha256 = previous.token_vector_sha256; + model.digests.token_vectors = previous.token_vectors; + model.digests.token_vector_rows = previous.token_vector_rows; + } + return 0; +} diff --git a/tests/test_github_workflows.sh b/tests/test_github_workflows.sh index 22ce7c51..93eca989 100644 --- a/tests/test_github_workflows.sh +++ b/tests/test_github_workflows.sh @@ -97,7 +97,7 @@ bash "$ROOT/scripts/ci-test-shard.sh" --verify grep -q 'CBM_WITH_ZOVA=1' "$WORKFLOWS/_build.yml" grep -q 'ZOVA_ROOT=' "$WORKFLOWS/_build.yml" grep -q 'zova_fresh_build_begin' "$WORKFLOWS/_build.yml" -grep -q 'cbm_zova_publish_model.c' "$ROOT/Makefile.cbm" +grep -q 'cbm_zova_publish_model.zig' "$ROOT/Makefile.cbm" grep -q 'if \$WITH_ZOVA' "$ROOT/scripts/build.sh" grep -q 'codebase-memory-mcp-linux-.*-portable.tar.gz' "$WORKFLOWS/_build.yml" grep -q 'os: darwin' "$WORKFLOWS/_build.yml" From 9f24d8e4f2f600e2a2c003eb6ebf3065ba14dfb0 Mon Sep 17 00:00:00 2001 From: ata-sesli Date: Tue, 10 Nov 2026 21:50:22 +0300 Subject: [PATCH 6/9] refactor(zova): port cbm_zova_operations from C to Zig src/zova/cbm_zova_operations.c (2,810 lines) is replaced by src/zova/cbm_zova_operations.zig. cbm_zova_operations.h stays the ABI; the 15 exported functions keep their names and signatures and are checked by checkExports in src/cbm_zig.zig. Form differences, no behavior difference intended: - internal helpers return bool or small enums instead of 0/-1/-2/-3/1 - goto cleanup is replaced by defer - the database and workspace archive verifiers share one prelude - manifest header and entries are formatted into stack buffers and written with fwrite instead of fprintf - the regular-file checks use the one cbm_lstat result; the redundant !S_ISLNK clause and the separate Windows stat branch are gone (cbm_lstat is stat on Windows) - status and the free-space check share one statvfs/GetDiskFreeSpaceExA helper Build wiring: - src/cbm_zig_c.h includes on POSIX - the Windows form of cbm_mkdir_mode is a comma expression that translate-c cannot express, so the Zig file calls cbm_mkdir there (the same _mkdir call) - tests/test_github_workflows.sh checks the Zig file for raw lstat/mkdir scripts/security-audit.sh: the two fopen("w") REVIEW lines for the manifest writers now name cbm_zova_operations.zig; the count is unchanged. Signed-off-by: ata-sesli --- Makefile.cbm | 2 +- build.zig | 1 - src/cbm_zig.zig | 1 + src/cbm_zig_c.h | 1 + src/zova/cbm_zova_operations.c | 2810 ------------------------------ src/zova/cbm_zova_operations.zig | 2383 +++++++++++++++++++++++++ tests/test_github_workflows.sh | 4 +- 7 files changed, 2388 insertions(+), 2814 deletions(-) delete mode 100644 src/zova/cbm_zova_operations.c create mode 100644 src/zova/cbm_zova_operations.zig diff --git a/Makefile.cbm b/Makefile.cbm index 7717a8c7..fdb2c384 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -153,7 +153,6 @@ PREPROCESSOR_SRC = $(CBM_DIR)/preprocessor.cpp # CBM_WITH_ZOVA=1 is set for the build. ZOVA_SRCS = \ src/zova/cbm_zova.c \ - src/zova/cbm_zova_operations.c \ src/zova/cbm_zova_repository.c # MCP server module: implemented in Zig (src/mcp/*.zig, part of libcbm_zig) @@ -486,6 +485,7 @@ CBM_ZIG_SRCS = $(CBM_ZIG_SRC) src/cbm_zig_c.h src/cli_zig/cbm_cli_zig.zig src/cl src/watcher/watcher.zig src/watcher/watcher.h \ src/zova/cbm_zova_bridge_stub.zig src/zova/cbm_zova_coordination.zig \ src/zova/cbm_zova_delta.zig src/zova/cbm_zova_edge_payload.zig \ + src/zova/cbm_zova_operations.zig \ src/zova/cbm_zova_plugin.zig src/zova/cbm_zova_publish_model.zig \ src/zova/cbm_zova_route.zig \ src/zova/cbm_zova_v5_snapshot.zig \ diff --git a/build.zig b/build.zig index 43353fd0..0cf6fede 100644 --- a/build.zig +++ b/build.zig @@ -537,7 +537,6 @@ const native_sources = [_][]const u8{ "src/foundation/hash_table.c", "src/foundation/c_shim.c", "src/zova/cbm_zova.c", - "src/zova/cbm_zova_operations.c", "src/zova/cbm_zova_repository.c", "src/pipeline/fqn.c", "src/pipeline/path_alias.c", diff --git a/src/cbm_zig.zig b/src/cbm_zig.zig index 64bdfa9b..19ba0a08 100644 --- a/src/cbm_zig.zig +++ b/src/cbm_zig.zig @@ -68,6 +68,7 @@ comptime { checkExports(@import("zova/cbm_zova_coordination.zig")); checkExports(@import("zova/cbm_zova_delta.zig")); checkExports(@import("zova/cbm_zova_edge_payload.zig")); + checkExports(@import("zova/cbm_zova_operations.zig")); checkExports(@import("zova/cbm_zova_plugin.zig")); checkExports(@import("zova/cbm_zova_publish_model.zig")); checkExports(@import("zova/cbm_zova_route.zig")); diff --git a/src/cbm_zig_c.h b/src/cbm_zig_c.h index b4309261..473c2025 100644 --- a/src/cbm_zig_c.h +++ b/src/cbm_zig_c.h @@ -22,6 +22,7 @@ #ifndef _WIN32 #include #include +#include #include #include #include diff --git a/src/zova/cbm_zova_operations.c b/src/zova/cbm_zova_operations.c deleted file mode 100644 index 0253fd02..00000000 --- a/src/zova/cbm_zova_operations.c +++ /dev/null @@ -1,2810 +0,0 @@ -#include "zova/cbm_zova_operations.h" - -#include "foundation/compat.h" -#include "foundation/compat_fs.h" -#include "foundation/sha256.h" -#include "foundation/log.h" -#include "pipeline/pipeline.h" -#include "zova/cbm_zova_repository.h" -#include "zova/cbm_zova_coordination.h" -#include "zova/cbm_zova_route.h" -#include "zova/cbm_zova_writer_gate.h" -#include "zova/cbm_zova_v5_snapshot.h" - -#include -#include -#include -#include -#include -#include -#include - -#ifndef _WIN32 -#include -#include -#include -#endif - -#if CBM_WITH_ZOVA -#include "zova.h" -#endif - -static void operations_report(cbm_zova_operation_report_t *out, - cbm_zova_operation_code_t code, - const char *operation, const char *reason) { - if (!out) return; - memset(out, 0, sizeof(*out)); - out->code = code; - snprintf(out->operation, sizeof(out->operation), "%s", operation ? operation : ""); - snprintf(out->reason, sizeof(out->reason), "%s", reason ? reason : ""); -} - -#if CBM_WITH_ZOVA -static int operations_step(zova_statement *statement, zova_step_result *out) { - return zova_statement_step(&(zova_statement_step_request){ - .statement = statement, .out_result = out}) == ZOVA_OK - ? 0 - : -1; -} - -static int operations_query_i64(zova_database *db, const char *sql, int64_t *out) { - zova_statement *statement = NULL; - if (!db || !sql || !out || - zova_database_prepare(&(zova_database_prepare_request){ - .db = db, .sql = sql, .out_statement = &statement}) != ZOVA_OK || - !statement) { - return -1; - } - zova_step_result step = ZOVA_STEP_DONE; - int rc = operations_step(statement, &step) == 0 && step == ZOVA_STEP_ROW && - zova_statement_column_int64(&(zova_statement_column_int64_request){ - .statement = statement, .index = 0, .out_value = out}) == ZOVA_OK - ? 0 - : -1; - (void)zova_statement_finalize(statement); - return rc; -} - -/* Statuses that say the database could not be read right now (locked by another - * connection, out of memory or file descriptors), not that it is damaged. */ -static bool operations_status_is_transient(zova_status status) { - return status == ZOVA_BUSY || status == ZOVA_LOCKED || status == ZOVA_OUT_OF_MEMORY || - status == ZOVA_CANT_OPEN; -} - -/* PRAGMA quick_check. out_status (optional) receives the status of a failed - * prepare or step; it stays ZOVA_OK when the check ran and found damage. */ -static int operations_quick_check_status(zova_database *db, zova_status *out_status) { - if (out_status) *out_status = ZOVA_OK; - zova_statement *statement = NULL; - zova_status status = zova_database_prepare(&(zova_database_prepare_request){ - .db = db, .sql = "PRAGMA quick_check", .out_statement = &statement}); - if (status != ZOVA_OK || !statement) { - if (out_status) *out_status = status; - return -1; - } - zova_step_result step = ZOVA_STEP_DONE; - status = zova_statement_step(&(zova_statement_step_request){ - .statement = statement, .out_result = &step}); - if (status != ZOVA_OK) { - if (out_status) *out_status = status; - (void)zova_statement_finalize(statement); - return -1; - } - zova_text text = {0}; - int rc = step == ZOVA_STEP_ROW && - zova_statement_column_text(&(zova_statement_column_text_request){ - .statement = statement, .index = 0, .out_text = &text}) == ZOVA_OK && - text.data && text.len == 2 && memcmp(text.data, "ok", 2) == 0 - ? 0 - : -1; - zova_text_free(&text); - (void)zova_statement_finalize(statement); - return rc; -} - -static int operations_quick_check(zova_database *db) { - return operations_quick_check_status(db, NULL); -} - -static int operations_foreign_keys_ok(zova_database *db) { - zova_statement *statement = NULL; - if (zova_database_prepare(&(zova_database_prepare_request){ - .db = db, .sql = "PRAGMA foreign_key_check", .out_statement = &statement}) != - ZOVA_OK || - !statement) { - return -1; - } - zova_step_result step = ZOVA_STEP_ROW; - int rc = operations_step(statement, &step) == 0 && step == ZOVA_STEP_DONE ? 0 : -1; - (void)zova_statement_finalize(statement); - return rc; -} - -static int operations_verify_database(const char *path, int *out_schema_version) { - zova_database *db = NULL; - zova_message error = {0}; - int rc = -1; - if (!path || - zova_database_open_with_options(&(zova_database_open_options_request){ - .path = path, .flags = ZOVA_OPEN_READ_ONLY, .busy_timeout_ms = 5000, - .out_db = &db, .out_error_message = &error}) != ZOVA_OK || - !db) { - zova_message_free(&error); - return -1; - } - zova_message_free(&error); - int64_t schema_version = 0; - int64_t invalid_generations = 0; - if (operations_query_i64( - db, "SELECT schema_version FROM cbm_database_schema_v1 WHERE id=1", - &schema_version) == 0 && - schema_version == CBM_ZOVA_DATABASE_SCHEMA_VERSION && operations_quick_check(db) == 0 && - operations_foreign_keys_ok(db) == 0 && - operations_query_i64( - db, - "SELECT count(*) FROM cbm_workspace_registry w LEFT JOIN " - "cbm_database_generation_v1 g ON g.workspace_key=w.workspace_key AND " - "g.generation=w.active_generation AND g.state='ready' LEFT JOIN " - "cbm_generation_integrity_v2 i ON i.workspace_key=w.workspace_key AND " - "i.generation=w.active_generation WHERE w.active_generation<=0 OR " - "g.workspace_key IS NULL OR i.workspace_key IS NULL", - &invalid_generations) == 0 && - invalid_generations == 0) { - rc = 0; - if (out_schema_version) *out_schema_version = (int)schema_version; - } - (void)zova_database_close(db); - return rc; -} - -static int operations_parent_directory(const char *path, char *out, size_t out_size) { - if (!path || !out || out_size == 0 || snprintf(out, out_size, "%s", path) >= (int)out_size) - return -1; - char *slash = strrchr(out, '/'); -#ifdef _WIN32 - char *backslash = strrchr(out, '\\'); - if (!slash || (backslash && backslash > slash)) slash = backslash; -#endif - if (!slash) return snprintf(out, out_size, ".") < (int)out_size ? 0 : -1; - if (slash == out) slash[1] = '\0'; - else *slash = '\0'; - return 0; -} - -static int operations_sync_directory(const char *path) { -#ifdef _WIN32 - (void)path; - return 0; -#else - int fd = open(path, O_RDONLY | O_DIRECTORY); - if (fd < 0) return -1; - int rc = fsync(fd); - close(fd); - return rc == 0 ? 0 : -1; -#endif -} - -static int operations_same_file(const char *left, const char *right) { - struct stat left_stat, right_stat; - if (stat(left, &left_stat) != 0 || stat(right, &right_stat) != 0) return 0; -#ifdef _WIN32 - return strcmp(left, right) == 0; -#else - return left_stat.st_dev == right_stat.st_dev && left_stat.st_ino == right_stat.st_ino; -#endif -} - -static int operations_path_is_regular_nosymlink(const char *path) { - struct stat value; -#ifdef _WIN32 - return stat(path, &value) == 0 && S_ISREG(value.st_mode); -#else - return cbm_lstat(path, &value) == 0 && S_ISREG(value.st_mode) && !S_ISLNK(value.st_mode); -#endif -} - -static int operations_restore_path_state(const char *path) { - struct stat value; - if (cbm_lstat(path, &value) != 0) return 0; -#ifdef _WIN32 - return S_ISREG(value.st_mode) ? 1 : -1; -#else - return S_ISREG(value.st_mode) && !S_ISLNK(value.st_mode) ? 1 : -1; -#endif -} - -/* Test hook: CBM_ZOVA_TEST_CRASH_PHASE exits at the named phase and - * CBM_ZOVA_TEST_FAIL_PHASE makes it fail. */ -static bool operations_test_fault(const char *phase) { - const char *crash = getenv("CBM_ZOVA_TEST_CRASH_PHASE"); - if (crash && strcmp(crash, phase) == 0) { -#ifdef _WIN32 - abort(); -#else - _exit(86); -#endif - } - const char *fault = getenv("CBM_ZOVA_TEST_FAIL_PHASE"); - return fault && strcmp(fault, phase) == 0; -} - -static int operations_replacement_fault(const char *operation, const char *phase, - cbm_zova_operation_report_t *out) { - if (!operations_test_fault(phase)) return 0; - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, operation, phase); - return 1; -} - -typedef struct { - int archive_version; - int schema_version; - uint64_t database_bytes; - char database_sha256[CBM_SHA256_HEX_LEN + 1]; -} operations_archive_manifest_t; - -static int operations_file_sha256(const char *path, char out[CBM_SHA256_HEX_LEN + 1], - uint64_t *out_bytes) { - FILE *file = fopen(path, "rb"); - if (!file) return -1; - cbm_sha256_ctx context; - cbm_sha256_init(&context); - uint8_t buffer[65536]; - uint64_t bytes = 0; - int rc = 0; - while (!feof(file)) { - size_t count = fread(buffer, 1, sizeof(buffer), file); - if (count) { - cbm_sha256_update(&context, buffer, count); - bytes += count; - } - if (ferror(file)) { - rc = -1; - break; - } - } - fclose(file); - if (rc != 0) return -1; - uint8_t digest[CBM_SHA256_DIGEST_LEN]; - cbm_sha256_final(&context, digest); - static const char hex[] = "0123456789abcdef"; - for (size_t i = 0; i < sizeof(digest); i++) { - out[i * 2] = hex[digest[i] >> 4]; - out[i * 2 + 1] = hex[digest[i] & 15]; - } - out[CBM_SHA256_HEX_LEN] = '\0'; - if (out_bytes) *out_bytes = bytes; - return 0; -} - -static int operations_workspace_manifest_entry(zova_statement *statement, char *out, - size_t out_size) { - zova_text values[6] = {{0}}; - int64_t generation = 0; - int rc = zova_statement_column_int64(&(zova_statement_column_int64_request){ - .statement = statement, .index = 1, .out_value = &generation}) == ZOVA_OK - ? 0 - : -1; - const int columns[] = {0, 2, 3, 4, 5, 6}; - for (size_t i = 0; rc == 0 && i < 6; i++) { - if (zova_statement_column_text(&(zova_statement_column_text_request){ - .statement = statement, .index = columns[i], .out_text = &values[i]}) != ZOVA_OK || - !values[i].data) { - rc = -1; - } - } - if (rc == 0) { - int written = snprintf( - out, out_size, - "{\"workspace_id\":\"%.*s\",\"generation\":%lld," - "\"metadata_sha256\":\"%.*s\",\"fts_sha256\":\"%.*s\"," - "\"topology_sha256\":\"%.*s\",\"node_vector_sha256\":\"%.*s\"," - "\"token_vector_sha256\":\"%.*s\"}", - (int)values[0].len, values[0].data, (long long)generation, - (int)values[1].len, values[1].data, (int)values[2].len, values[2].data, - (int)values[3].len, values[3].data, (int)values[4].len, values[4].data, - (int)values[5].len, values[5].data); - if (written < 0 || written >= (int)out_size) rc = -1; - } - for (size_t i = 0; i < 6; i++) zova_text_free(&values[i]); - return rc; -} - -static int operations_workspace_manifest_query(zova_database *db, zova_statement **out) { - return zova_database_prepare(&(zova_database_prepare_request){ - .db = db, - .sql = "SELECT r.workspace_id,r.active_generation,i.metadata_sha256," - "i.fts_sha256,i.topology_sha256,i.node_vector_sha256," - "i.token_vector_sha256 FROM cbm_workspace_registry r JOIN " - "cbm_generation_integrity_v2 i ON i.workspace_key=r.workspace_key AND " - "i.generation=r.active_generation ORDER BY r.workspace_id", - .out_statement = out}) == ZOVA_OK && *out - ? 0 - : -1; -} - -static char *operations_json_escape_alloc(const char *value) { - if (!value) return NULL; - size_t length = strlen(value); - if (length > (SIZE_MAX - 1) / 6) return NULL; - char *escaped = malloc(length * 6 + 1); - if (!escaped) return NULL; - size_t used = 0; - static const char hex[] = "0123456789abcdef"; - for (const unsigned char *cursor = (const unsigned char *)value; *cursor; cursor++) { - const char *short_escape = NULL; - switch (*cursor) { - case '"': short_escape = "\\\""; break; - case '\\': short_escape = "\\\\"; break; - case '\b': short_escape = "\\b"; break; - case '\f': short_escape = "\\f"; break; - case '\n': short_escape = "\\n"; break; - case '\r': short_escape = "\\r"; break; - case '\t': short_escape = "\\t"; break; - default: break; - } - if (short_escape) { - escaped[used++] = short_escape[0]; - escaped[used++] = short_escape[1]; - } else if (*cursor < 0x20) { - escaped[used++] = '\\'; - escaped[used++] = 'u'; - escaped[used++] = '0'; - escaped[used++] = '0'; - escaped[used++] = hex[*cursor >> 4]; - escaped[used++] = hex[*cursor & 15]; - } else { - escaped[used++] = (char)*cursor; - } - } - escaped[used] = '\0'; - return escaped; -} - -static int operations_workspace_archive_manifest_text( - const operations_archive_manifest_t *manifest, - const cbm_zova_workspace_snapshot_t *snapshot, char **out_text) { - if (!manifest || !snapshot || !out_text) return -1; - char *workspace_id = operations_json_escape_alloc(snapshot->workspace_id); - char *root = operations_json_escape_alloc(snapshot->root_path); - char *project = operations_json_escape_alloc(snapshot->project); - char *model = operations_json_escape_alloc(snapshot->model_fingerprint); - if (!workspace_id || !root || !project || !model) { - free(workspace_id); free(root); free(project); free(model); - return -1; - } - const cbm_zova_workspace_generation_result_t *integrity = &snapshot->integrity; - const char *format = - "{\"archive_version\":%d,\"schema_version\":%d,\"archive_kind\":\"workspace\"," - "\"database\":{\"path\":\"data.zova\",\"bytes\":%llu,\"sha256\":\"%s\"}," - "\"workspace\":{\"workspace_id\":\"%s\",\"canonical_root\":\"%s\"," - "\"project\":\"%s\",\"model_fingerprint\":\"%s\",\"vector_dimensions\":%d," - "\"generation\":%lld,\"graph_nodes\":%llu,\"graph_edges\":%llu," - "\"metadata_nodes\":%llu,\"metadata_edges\":%llu," - "\"metadata_topology_edges\":%llu,\"fts_rows\":%llu," - "\"node_vector_rows\":%llu,\"token_vector_rows\":%llu," - "\"node_vectors\":%llu,\"token_vectors\":%llu," - "\"metadata_sha256\":\"%s\",\"fts_sha256\":\"%s\"," - "\"topology_sha256\":\"%s\",\"node_vector_sha256\":\"%s\"," - "\"token_vector_sha256\":\"%s\"}}\n"; - int needed = snprintf( - NULL, 0, format, manifest->archive_version, manifest->schema_version, - (unsigned long long)manifest->database_bytes, manifest->database_sha256, workspace_id, - root, project, model, snapshot->vector_dimensions, (long long)snapshot->generation, - (unsigned long long)integrity->graph_nodes, - (unsigned long long)integrity->graph_edges, - (unsigned long long)integrity->metadata_nodes, - (unsigned long long)integrity->metadata_edges, - (unsigned long long)integrity->metadata_topology_edges, - (unsigned long long)integrity->fts_rows, - (unsigned long long)integrity->node_vector_rows, - (unsigned long long)integrity->token_vector_rows, - (unsigned long long)integrity->node_vectors, - (unsigned long long)integrity->token_vectors, integrity->metadata_sha256, - integrity->fts_sha256, integrity->topology_sha256, integrity->node_vector_sha256, - integrity->token_vector_sha256); - char *text = needed >= 0 ? malloc((size_t)needed + 1) : NULL; - if (!text || snprintf( - text, (size_t)needed + 1, format, manifest->archive_version, - manifest->schema_version, (unsigned long long)manifest->database_bytes, - manifest->database_sha256, workspace_id, root, project, model, - snapshot->vector_dimensions, (long long)snapshot->generation, - (unsigned long long)integrity->graph_nodes, - (unsigned long long)integrity->graph_edges, - (unsigned long long)integrity->metadata_nodes, - (unsigned long long)integrity->metadata_edges, - (unsigned long long)integrity->metadata_topology_edges, - (unsigned long long)integrity->fts_rows, - (unsigned long long)integrity->node_vector_rows, - (unsigned long long)integrity->token_vector_rows, - (unsigned long long)integrity->node_vectors, - (unsigned long long)integrity->token_vectors, integrity->metadata_sha256, - integrity->fts_sha256, integrity->topology_sha256, - integrity->node_vector_sha256, integrity->token_vector_sha256) != needed) { - free(text); - text = NULL; - } - free(workspace_id); free(root); free(project); free(model); - if (!text) return -1; - *out_text = text; - return 0; -} - -static int operations_write_workspace_archive_manifest( - const char *manifest_path, const operations_archive_manifest_t *manifest, - const cbm_zova_workspace_snapshot_t *snapshot) { - char *text = NULL; - if (operations_workspace_archive_manifest_text(manifest, snapshot, &text) != 0) return -1; - FILE *file = fopen(manifest_path, "wb"); - size_t length = strlen(text); - int rc = file && fwrite(text, 1, length, file) == length && fflush(file) == 0 ? 0 : -1; -#ifndef _WIN32 - if (rc == 0 && fsync(fileno(file)) != 0) rc = -1; -#endif - if (file) fclose(file); - free(text); - return rc; -} - -static int operations_write_archive_manifest(const char *data_path, const char *manifest_path, - const operations_archive_manifest_t *manifest) { - zova_database *db = NULL; - zova_message error = {0}; - if (zova_database_open_with_options(&(zova_database_open_options_request){ - .path = data_path, .flags = ZOVA_OPEN_READ_ONLY, .busy_timeout_ms = 5000, - .out_db = &db, .out_error_message = &error}) != ZOVA_OK || !db) { - zova_message_free(&error); - return -1; - } - zova_message_free(&error); - zova_statement *statement = NULL; - FILE *file = fopen(manifest_path, "wb"); - int rc = file && operations_workspace_manifest_query(db, &statement) == 0 ? 0 : -1; - if (rc == 0 && fprintf(file, - "{\"archive_version\":%d,\"schema_version\":%d," - "\"database\":{\"path\":\"data.zova\",\"bytes\":%llu," - "\"sha256\":\"%s\"},\"workspaces\":[", - manifest->archive_version, manifest->schema_version, - (unsigned long long)manifest->database_bytes, - manifest->database_sha256) < 0) - rc = -1; - int first = 1; - while (rc == 0) { - zova_step_result step = ZOVA_STEP_DONE; - if (operations_step(statement, &step) != 0) { - rc = -1; - break; - } - if (step == ZOVA_STEP_DONE) break; - char entry[1024]; - if (operations_workspace_manifest_entry(statement, entry, sizeof(entry)) != 0 || - fprintf(file, "%s%s", first ? "" : ",", entry) < 0) - rc = -1; - first = 0; - } - if (rc == 0 && (fprintf(file, "]}\n") < 0 || fflush(file) != 0)) rc = -1; -#ifndef _WIN32 - if (rc == 0 && fsync(fileno(file)) != 0) rc = -1; -#endif - if (statement) (void)zova_statement_finalize(statement); - if (file) fclose(file); - (void)zova_database_close(db); - return rc; -} - -static int operations_read_file(const char *path, char **out_text) { - struct stat value; - if (stat(path, &value) != 0 || value.st_size < 0 || value.st_size > 16 * 1024 * 1024) - return -1; - FILE *file = fopen(path, "rb"); - if (!file) return -1; - char *text = malloc((size_t)value.st_size + 1); - int rc = text && fread(text, 1, (size_t)value.st_size, file) == (size_t)value.st_size && - !ferror(file) - ? 0 - : -1; - fclose(file); - if (rc != 0) { - free(text); - return -1; - } - text[value.st_size] = '\0'; - *out_text = text; - return 0; -} - -static int operations_remove_closed_database_sidecars(const char *database_path) { - static const char *const suffixes[] = {"-wal", "-shm", "-journal"}; - char path[4096]; - for (size_t i = 0; i < sizeof(suffixes) / sizeof(suffixes[0]); i++) { - if (snprintf(path, sizeof(path), "%s%s", database_path, suffixes[i]) >= - (int)sizeof(path)) - return -1; - int state = operations_restore_path_state(path); - if (state < 0 || (state == 1 && cbm_unlink(path) != 0)) return -1; - } - return 0; -} - -static int operations_remove_closed_writer_lock(const char *database_path) { - char path[4096]; - if (snprintf(path, sizeof(path), "%s.writer.lock", database_path) >= (int)sizeof(path)) - return -1; - int state = operations_restore_path_state(path); - return state < 0 || (state == 1 && cbm_unlink(path) != 0) ? -1 : 0; -} - -static int operations_archive_has_exact_members(const char *archive) { - cbm_dir_t *directory = cbm_opendir(archive); - if (!directory) return -1; - int data_seen = 0; - int manifest_seen = 0; - int valid = 1; - cbm_dirent_t *entry = NULL; - while (valid && (entry = cbm_readdir(directory)) != NULL) { - int is_data = strcmp(entry->name, "data.zova") == 0; - int is_manifest = strcmp(entry->name, "manifest.json") == 0; - char path[4096]; - if ((!is_data && !is_manifest) || - snprintf(path, sizeof(path), "%s/%s", archive, entry->name) >= - (int)sizeof(path) || - !operations_path_is_regular_nosymlink(path)) { - valid = 0; - break; - } - if (is_data) data_seen++; - if (is_manifest) manifest_seen++; - } - cbm_closedir(directory); - return valid && data_seen == 1 && manifest_seen == 1 ? 0 : -1; -} - -static int operations_cleanup_verified_archive_reads(const char *archive) { - char data[4096]; - if (snprintf(data, sizeof(data), "%s/data.zova", archive) >= (int)sizeof(data)) return -1; - return operations_remove_closed_database_sidecars(data) == 0 && - operations_archive_has_exact_members(archive) == 0 - ? 0 : -1; -} - -static int operations_single_ready_workspace(const char *data_path, - char out[CBM_ZOVA_WORKSPACE_ID_MAX]) { - zova_database *db = NULL; - zova_message error = {0}; - zova_statement *statement = NULL; - int rc = zova_database_open_with_options(&(zova_database_open_options_request){ - .path = data_path, .flags = ZOVA_OPEN_READ_ONLY, .busy_timeout_ms = 5000, - .out_db = &db, .out_error_message = &error}) == ZOVA_OK && db - ? 0 : -1; - zova_message_free(&error); - if (rc == 0) - rc = zova_database_prepare(&(zova_database_prepare_request){ - .db = db, - .sql = "SELECT r.workspace_id FROM cbm_workspace_registry r " - "JOIN cbm_database_generation_v1 g ON g.workspace_key=r.workspace_key " - "AND g.generation=r.active_generation AND g.state='ready' " - "JOIN cbm_workspace_index_state_v1 s ON s.workspace_key=r.workspace_key " - "AND s.generation=r.active_generation ORDER BY r.workspace_id", - .out_statement = &statement}) == ZOVA_OK && statement ? 0 : -1; - zova_step_result step = ZOVA_STEP_DONE; - zova_text workspace = {0}; - if (rc == 0 && - (operations_step(statement, &step) != 0 || step != ZOVA_STEP_ROW || - zova_statement_column_text(&(zova_statement_column_text_request){ - .statement = statement, .index = 0, .out_text = &workspace}) != ZOVA_OK || - !workspace.data || workspace.len >= CBM_ZOVA_WORKSPACE_ID_MAX)) rc = -1; - if (rc == 0) { - memcpy(out, workspace.data, workspace.len); - out[workspace.len] = '\0'; - if (operations_step(statement, &step) != 0 || step != ZOVA_STEP_DONE || - cbm_zova_workspace_id_validate(out) != 0) rc = -1; - } - zova_text_free(&workspace); - if (statement) (void)zova_statement_finalize(statement); - if (db) (void)zova_database_close(db); - return rc; -} - -static int operations_workspace_count(const char *path, int64_t *out) { - zova_database *db = NULL; - zova_message error = {0}; - int rc = zova_database_open_with_options(&(zova_database_open_options_request){ - .path = path, .flags = ZOVA_OPEN_READ_ONLY, .busy_timeout_ms = 5000, - .out_db = &db, .out_error_message = &error}) == ZOVA_OK && db - ? operations_query_i64(db, "SELECT count(*) FROM cbm_workspace_registry", out) - : -1; - zova_message_free(&error); - if (db) (void)zova_database_close(db); - return rc; -} - -static int operations_generation_matches( - const cbm_zova_workspace_generation_result_t *actual, - const cbm_zova_workspace_generation_result_t *expected) { - int matches = actual->generation == expected->generation && - strcmp(actual->workspace_id, expected->workspace_id) == 0 && - actual->graph_nodes == expected->graph_nodes && - actual->graph_edges == expected->graph_edges && - actual->metadata_nodes == expected->metadata_nodes && - actual->metadata_edges == expected->metadata_edges && - actual->metadata_topology_edges == expected->metadata_topology_edges && - actual->fts_rows == expected->fts_rows && - actual->node_vector_rows == expected->node_vector_rows && - actual->token_vector_rows == expected->token_vector_rows && - actual->node_vectors == expected->node_vectors && - actual->token_vectors == expected->token_vectors && - strcmp(actual->metadata_sha256, expected->metadata_sha256) == 0 && - strcmp(actual->fts_sha256, expected->fts_sha256) == 0 && - strcmp(actual->topology_sha256, expected->topology_sha256) == 0 && - strcmp(actual->node_vector_sha256, expected->node_vector_sha256) == 0 && - strcmp(actual->token_vector_sha256, expected->token_vector_sha256) == 0; - return matches; -} - -/* 0 valid, -1 malformed/verification failure, -2 incompatible, -3 path alias. */ -static int operations_verify_archive(const char *archive, const char *live_path, - operations_archive_manifest_t *out) { - struct stat value; - if (cbm_lstat(archive, &value) != 0 || !S_ISDIR(value.st_mode) -#ifndef _WIN32 - || S_ISLNK(value.st_mode) -#endif - ) - return -1; - if (operations_archive_has_exact_members(archive) != 0) return -1; - char data[4096], manifest_path[4096]; - if (snprintf(data, sizeof(data), "%s/data.zova", archive) >= (int)sizeof(data) || - snprintf(manifest_path, sizeof(manifest_path), "%s/manifest.json", archive) >= - (int)sizeof(manifest_path) || - !operations_path_is_regular_nosymlink(data) || - !operations_path_is_regular_nosymlink(manifest_path)) - return -1; - if (live_path && operations_restore_path_state(live_path) == 1 && - operations_same_file(data, live_path)) - return -3; - char *text = NULL; - if (operations_read_file(manifest_path, &text) != 0) return -1; - operations_archive_manifest_t parsed = {0}; - unsigned long long bytes = 0; - int consumed = 0; - int fields = sscanf(text, - "{\"archive_version\":%d,\"schema_version\":%d," - "\"database\":{\"path\":\"data.zova\",\"bytes\":%llu," - "\"sha256\":\"%64[0-9a-f]\"},\"workspaces\":[%n", - &parsed.archive_version, &parsed.schema_version, &bytes, - parsed.database_sha256, &consumed); - parsed.database_bytes = (uint64_t)bytes; - if (fields != 4 || strlen(parsed.database_sha256) != CBM_SHA256_HEX_LEN) { - free(text); - return -1; - } - if (parsed.archive_version != CBM_ZOVA_OPERATIONS_ARCHIVE_VERSION || - parsed.schema_version != CBM_ZOVA_DATABASE_SCHEMA_VERSION) { - free(text); - return -2; - } - char digest[CBM_SHA256_HEX_LEN + 1]; - uint64_t actual_bytes = 0; - if (operations_file_sha256(data, digest, &actual_bytes) != 0 || - actual_bytes != parsed.database_bytes || strcmp(digest, parsed.database_sha256) != 0 || - operations_verify_database(data, NULL) != 0) { - free(text); - return -1; - } - zova_database *db = NULL; - zova_message error = {0}; - zova_statement *statement = NULL; - int rc = zova_database_open_with_options(&(zova_database_open_options_request){ - .path = data, .flags = ZOVA_OPEN_READ_ONLY, .busy_timeout_ms = 5000, - .out_db = &db, .out_error_message = &error}) == ZOVA_OK && db && - operations_workspace_manifest_query(db, &statement) == 0 - ? 0 - : -1; - zova_message_free(&error); - const char *cursor = text + consumed; - int first = 1; - while (rc == 0) { - zova_step_result step = ZOVA_STEP_DONE; - if (operations_step(statement, &step) != 0) { - rc = -1; - break; - } - if (step == ZOVA_STEP_DONE) break; - char entry[1024]; - if (operations_workspace_manifest_entry(statement, entry, sizeof(entry)) != 0) { - rc = -1; - break; - } - if (!first) { - if (*cursor != ',') { - rc = -1; - break; - } - cursor++; - } - size_t length = strlen(entry); - if (strncmp(cursor, entry, length) != 0) { - rc = -1; - break; - } - cursor += length; - first = 0; - } - if (rc == 0 && strcmp(cursor, "]}\n") != 0) rc = -1; - if (statement) (void)zova_statement_finalize(statement); - if (db) (void)zova_database_close(db); - free(text); - if (rc == 0 && out) *out = parsed; - return rc; -} - -/* 0 valid, -1 malformed/verification failure, -2 incompatible, -3 path alias. */ -static int operations_verify_workspace_archive(const char *archive, const char *live_path, - operations_archive_manifest_t *out) { - struct stat value; - if (cbm_lstat(archive, &value) != 0 || !S_ISDIR(value.st_mode) -#ifndef _WIN32 - || S_ISLNK(value.st_mode) -#endif - ) return -1; - if (operations_archive_has_exact_members(archive) != 0) return -1; - char data[4096], manifest_path[4096]; - if (snprintf(data, sizeof(data), "%s/data.zova", archive) >= (int)sizeof(data) || - snprintf(manifest_path, sizeof(manifest_path), "%s/manifest.json", archive) >= - (int)sizeof(manifest_path) || - !operations_path_is_regular_nosymlink(data) || - !operations_path_is_regular_nosymlink(manifest_path)) return -1; - if (live_path && operations_restore_path_state(live_path) == 1 && - operations_same_file(data, live_path)) return -3; - char *text = NULL; - if (operations_read_file(manifest_path, &text) != 0) return -1; - operations_archive_manifest_t parsed = {0}; - unsigned long long bytes = 0; - int consumed = 0; - int fields = sscanf(text, - "{\"archive_version\":%d,\"schema_version\":%d," - "\"archive_kind\":\"workspace\"," - "\"database\":{\"path\":\"data.zova\",\"bytes\":%llu," - "\"sha256\":\"%64[0-9a-f]\"},\"workspace\":%n", - &parsed.archive_version, &parsed.schema_version, &bytes, - parsed.database_sha256, &consumed); - parsed.database_bytes = (uint64_t)bytes; - if (fields != 4 || consumed <= 0 || - strlen(parsed.database_sha256) != CBM_SHA256_HEX_LEN) { - free(text); - return -1; - } - if (parsed.archive_version != CBM_ZOVA_OPERATIONS_ARCHIVE_VERSION || - parsed.schema_version != CBM_ZOVA_DATABASE_SCHEMA_VERSION) { - free(text); - return -2; - } - char digest[CBM_SHA256_HEX_LEN + 1]; - uint64_t actual_bytes = 0; - if (operations_file_sha256(data, digest, &actual_bytes) != 0 || - actual_bytes != parsed.database_bytes || strcmp(digest, parsed.database_sha256) != 0 || - operations_verify_database(data, NULL) != 0) { - free(text); - return -1; - } - char workspace_id[CBM_ZOVA_WORKSPACE_ID_MAX] = {0}; - cbm_zova_workspace_snapshot_t snapshot = {0}; - char *expected = NULL; - int rc = operations_single_ready_workspace(data, workspace_id) == 0 && - cbm_zova_repository_export_snapshot(data, workspace_id, &snapshot) == 0 && - operations_workspace_archive_manifest_text(&parsed, &snapshot, &expected) == 0 && - strcmp(text, expected) == 0 - ? 0 : -1; - free(expected); - cbm_zova_workspace_snapshot_free(&snapshot); - free(text); - if (rc == 0 && out) *out = parsed; - return rc; -} - -static void operations_remove_partial_archive(const char *partial) { - char path[4096]; - if (snprintf(path, sizeof(path), "%s/manifest.json", partial) < (int)sizeof(path)) - (void)cbm_unlink(path); - if (snprintf(path, sizeof(path), "%s/data.zova", partial) < (int)sizeof(path)) { - (void)operations_remove_closed_database_sidecars(path); - (void)operations_remove_closed_writer_lock(path); - (void)cbm_unlink(path); - } - (void)rmdir(partial); -} - -#endif - -const char *cbm_zova_operation_code_name(cbm_zova_operation_code_t code) { - switch (code) { - case CBM_ZOVA_OPERATION_OK: return "ok"; - case CBM_ZOVA_OPERATION_NOOP: return "noop"; - case CBM_ZOVA_OPERATION_INVALID: return "invalid"; - case CBM_ZOVA_OPERATION_BUSY: return "busy"; - case CBM_ZOVA_OPERATION_INCOMPATIBLE: return "incompatible"; - case CBM_ZOVA_OPERATION_VERIFY_FAILED: return "verify_failed"; - case CBM_ZOVA_OPERATION_DISK_REFUSED: return "disk_refused"; - case CBM_ZOVA_OPERATION_CONFIRMATION_REQUIRED: return "confirmation_required"; - case CBM_ZOVA_OPERATION_WORKSPACE_REBUILD_REQUIRED: return "workspace_rebuild_required"; - case CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED: - return "whole_file_recovery_required"; - } - return "unknown"; -} - -cbm_zova_operation_code_t cbm_zova_database_status( - const char *path, cbm_zova_operation_report_t *out) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "status", "invalid_argument"); - if (!path || !path[0] || !out) return CBM_ZOVA_OPERATION_INVALID; -#if !CBM_WITH_ZOVA - operations_report(out, CBM_ZOVA_OPERATION_INCOMPATIBLE, "status", "zova_disabled"); - return CBM_ZOVA_OPERATION_INCOMPATIBLE; -#else - struct stat database_stat = {0}; - if (!operations_path_is_regular_nosymlink(path) || stat(path, &database_stat) != 0 || - database_stat.st_size < 0) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "status", "database_not_regular"); - return CBM_ZOVA_OPERATION_INVALID; - } - char wal_path[4096]; - struct stat wal_stat = {0}; - int wal_present = 0; - if (snprintf(wal_path, sizeof(wal_path), "%s-wal", path) >= (int)sizeof(wal_path)) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "status", "path_too_long"); - return CBM_ZOVA_OPERATION_INVALID; - } - if (cbm_lstat(wal_path, &wal_stat) == 0) { - if (!operations_path_is_regular_nosymlink(wal_path) || wal_stat.st_size < 0) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "status", "wal_not_regular"); - return CBM_ZOVA_OPERATION_INVALID; - } - wal_present = 1; - } - - uint64_t free_bytes = 0; -#ifdef _WIN32 - char parent[4096]; - ULARGE_INTEGER available = {0}; - if (operations_parent_directory(path, parent, sizeof(parent)) != 0 || - !GetDiskFreeSpaceExA(parent, &available, NULL, NULL)) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "status", - "free_space_unavailable"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - free_bytes = (uint64_t)available.QuadPart; -#else - struct statvfs filesystem = {0}; - if (statvfs(path, &filesystem) != 0) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "status", - "free_space_unavailable"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - uint64_t fragment_size = filesystem.f_frsize ? (uint64_t)filesystem.f_frsize - : (uint64_t)filesystem.f_bsize; - if (fragment_size == 0 || (uint64_t)filesystem.f_bavail > UINT64_MAX / fragment_size) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "status", - "free_space_overflow"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - free_bytes = (uint64_t)filesystem.f_bavail * fragment_size; -#endif - - struct timespec started = {0}, finished = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - zova_database *db = NULL; - zova_message error = {0}; - if (zova_database_open_with_options(&(zova_database_open_options_request){ - .path = path, .flags = ZOVA_OPEN_READ_ONLY, .busy_timeout_ms = 5000, - .out_db = &db, .out_error_message = &error}) != ZOVA_OK || !db) { - zova_message_free(&error); - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "status", "database_open_failed"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - zova_message_free(&error); - int64_t schema_version = 0; - int64_t invalid_generations = 0; - int64_t page_size = -1; - int64_t page_count = -1; - int64_t freelist_count = -1; - int schema_read = operations_query_i64( - db, "SELECT schema_version FROM cbm_database_schema_v1 WHERE id=1", &schema_version); - if (schema_read != 0 || schema_version != CBM_ZOVA_DATABASE_SCHEMA_VERSION) { - (void)zova_database_close(db); - if (schema_read == 0 && schema_version > CBM_ZOVA_DATABASE_SCHEMA_VERSION) { - operations_report(out, CBM_ZOVA_OPERATION_INCOMPATIBLE, "status", - "schema_version_incompatible"); - out->schema_version = schema_version > INT_MAX ? 0 : (int)schema_version; - return CBM_ZOVA_OPERATION_INCOMPATIBLE; - } - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "status", - "current_schema_required"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - int verified = cbm_zova_user_database_schema_is_current(db) == 0 && - operations_quick_check(db) == 0 && - operations_foreign_keys_ok(db) == 0 && - operations_query_i64( - db, - "SELECT count(*) FROM cbm_workspace_registry w LEFT JOIN " - "cbm_database_generation_v1 g ON g.workspace_key=w.workspace_key " - "AND g.generation=w.active_generation AND g.state='ready' LEFT JOIN " - "cbm_generation_integrity_v2 i ON i.workspace_key=w.workspace_key " - "AND i.generation=w.active_generation WHERE w.active_generation<=0 " - "OR g.workspace_key IS NULL OR i.workspace_key IS NULL", - &invalid_generations) == 0 && - invalid_generations == 0 && - operations_query_i64(db, "PRAGMA page_size", &page_size) == 0 && - operations_query_i64(db, "PRAGMA page_count", &page_count) == 0 && - operations_query_i64(db, "PRAGMA freelist_count", &freelist_count) == 0; - (void)zova_database_close(db); - if (!verified || page_size <= 0 || page_count < 0 || freelist_count < 0) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "status", - "database_verification_failed"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - uint64_t unsigned_page_size = (uint64_t)page_size; - uint64_t unsigned_freelist_count = (uint64_t)freelist_count; - if (unsigned_freelist_count > UINT64_MAX / unsigned_page_size) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "status", - "reclaimable_bytes_overflow"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - operations_report(out, CBM_ZOVA_OPERATION_OK, "status", "ok"); - out->schema_version = (int)schema_version; - out->database_bytes = (uint64_t)database_stat.st_size; - out->wal_bytes = wal_present ? (uint64_t)wal_stat.st_size : 0; - out->free_bytes = free_bytes; - out->page_size = unsigned_page_size; - out->page_count = (uint64_t)page_count; - out->freelist_count = unsigned_freelist_count; - out->reclaimable_bytes = unsigned_page_size * unsigned_freelist_count; - out->elapsed_ms = ((double)(finished.tv_sec - started.tv_sec) * 1000.0) + - ((double)(finished.tv_nsec - started.tv_nsec) / 1000000.0); - return CBM_ZOVA_OPERATION_OK; -#endif -} - -static void operations_copy_status_metrics(cbm_zova_operation_report_t *out, - const cbm_zova_operation_report_t *status) { - out->schema_version = status->schema_version; - out->database_bytes = status->database_bytes; - out->wal_bytes = status->wal_bytes; - out->free_bytes = status->free_bytes; - out->page_size = status->page_size; - out->page_count = status->page_count; - out->freelist_count = status->freelist_count; -} - -static cbm_zova_operation_code_t cbm_zova_workspace_delete_body( - const char *path, const char *workspace_id, const char *confirmation, - cbm_zova_operation_report_t *out) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "delete_workspace", "invalid_argument"); - if (!path || !path[0] || !workspace_id || !workspace_id[0] || !out) - return CBM_ZOVA_OPERATION_INVALID; - if (!confirmation || !confirmation[0]) { - operations_report(out, CBM_ZOVA_OPERATION_CONFIRMATION_REQUIRED, "delete_workspace", - "confirmation_required"); - return CBM_ZOVA_OPERATION_CONFIRMATION_REQUIRED; - } - if (strcmp(workspace_id, confirmation) != 0) { - operations_report(out, CBM_ZOVA_OPERATION_CONFIRMATION_REQUIRED, "delete_workspace", - "confirmation_mismatch"); - return CBM_ZOVA_OPERATION_CONFIRMATION_REQUIRED; - } - if (cbm_zova_workspace_id_validate(workspace_id) != 0) - return CBM_ZOVA_OPERATION_INVALID; -#if !CBM_WITH_ZOVA - operations_report(out, CBM_ZOVA_OPERATION_INCOMPATIBLE, "delete_workspace", "zova_disabled"); - return CBM_ZOVA_OPERATION_INCOMPATIBLE; -#else - struct timespec started = {0}, finished = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - cbm_zova_operation_report_t before = {0}; - cbm_zova_operation_code_t status = cbm_zova_database_status(path, &before); - if (status != CBM_ZOVA_OPERATION_OK) { - operations_report(out, status, "delete_workspace", "status_failed"); - return status; - } - cbm_zova_workspace_snapshot_t snapshot = {0}; - if (cbm_zova_repository_export_snapshot(path, workspace_id, &snapshot) != 0) { - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - operations_report(out, CBM_ZOVA_OPERATION_NOOP, "delete_workspace", "already_absent"); - snprintf(out->workspace_id, sizeof(out->workspace_id), "%s", workspace_id); - operations_copy_status_metrics(out, &before); - out->reclaimable_bytes = 0; - out->elapsed_ms = ((double)(finished.tv_sec - started.tv_sec) * 1000.0) + - ((double)(finished.tv_nsec - started.tv_nsec) / 1000000.0); - return CBM_ZOVA_OPERATION_NOOP; - } - int64_t generation = snapshot.generation; - cbm_zova_workspace_snapshot_free(&snapshot); - if (cbm_zova_user_database_delete_workspace(path, workspace_id) != 0) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "delete_workspace", - "delete_failed"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - cbm_zova_operation_report_t after = {0}; - if (cbm_zova_database_status(path, &after) != CBM_ZOVA_OPERATION_OK) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "delete_workspace", - "post_delete_status_failed"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - operations_report(out, CBM_ZOVA_OPERATION_OK, "delete_workspace", "ok"); - snprintf(out->workspace_id, sizeof(out->workspace_id), "%s", workspace_id); - out->generation = generation; - operations_copy_status_metrics(out, &after); - out->reclaimable_bytes = after.reclaimable_bytes >= before.reclaimable_bytes - ? after.reclaimable_bytes - before.reclaimable_bytes - : 0; - out->elapsed_ms = ((double)(finished.tv_sec - started.tv_sec) * 1000.0) + - ((double)(finished.tv_nsec - started.tv_nsec) / 1000000.0); - return CBM_ZOVA_OPERATION_OK; -#endif -} - -#if CBM_WITH_ZOVA -static int operations_checkpoint_truncate(zova_database *db) { - zova_statement *statement = NULL; - if (!db || zova_database_prepare(&(zova_database_prepare_request){ - .db = db, .sql = "PRAGMA wal_checkpoint(TRUNCATE)", - .out_statement = &statement}) != ZOVA_OK || !statement) - return -1; - zova_step_result step = ZOVA_STEP_DONE; - int64_t busy = -1, log_frames = -1, checkpointed_frames = -1; - int rc = operations_step(statement, &step) == 0 && step == ZOVA_STEP_ROW && - zova_statement_column_int64(&(zova_statement_column_int64_request){ - .statement = statement, .index = 0, .out_value = &busy}) == ZOVA_OK && - zova_statement_column_int64(&(zova_statement_column_int64_request){ - .statement = statement, .index = 1, .out_value = &log_frames}) == - ZOVA_OK && - zova_statement_column_int64(&(zova_statement_column_int64_request){ - .statement = statement, .index = 2, - .out_value = &checkpointed_frames}) == ZOVA_OK && - busy == 0 && log_frames == checkpointed_frames - ? 0 : -1; - (void)zova_statement_finalize(statement); - return rc; -} - -/* Bytes available to this user on the filesystem holding `path`. */ -static int operations_free_bytes(const char *path, uint64_t *out) { -#ifdef _WIN32 - char parent[4096]; - ULARGE_INTEGER available = {0}; - if (operations_parent_directory(path, parent, sizeof(parent)) != 0 || - !GetDiskFreeSpaceExA(parent, &available, NULL, NULL)) - return -1; - *out = (uint64_t)available.QuadPart; -#else - struct statvfs filesystem = {0}; - if (statvfs(path, &filesystem) != 0) return -1; - uint64_t fragment_size = filesystem.f_frsize ? (uint64_t)filesystem.f_frsize - : (uint64_t)filesystem.f_bsize; - if (fragment_size == 0 || (uint64_t)filesystem.f_bavail > UINT64_MAX / fragment_size) - return -1; - *out = (uint64_t)filesystem.f_bavail * fragment_size; -#endif - return 0; -} - -static int operations_raw_sqlite_checkpoint_truncate(const char *path) { - sqlite3 *db=NULL; - int log_frames=0,checkpointed_frames=0; - int rc=sqlite3_open_v2(path,&db,SQLITE_OPEN_READWRITE,NULL); - if(rc==SQLITE_OK)sqlite3_busy_timeout(db,5000); - if(rc==SQLITE_OK)rc=sqlite3_wal_checkpoint_v2(db,NULL,SQLITE_CHECKPOINT_TRUNCATE, - &log_frames,&checkpointed_frames); - if(db)sqlite3_close(db); - return rc==SQLITE_OK&&checkpointed_frames==log_frames?0:-1; -} - -static int operations_compact_below_threshold( - const cbm_zova_operation_report_t *status) { - const uint64_t absolute_threshold = 64ULL * 1024ULL * 1024ULL; - int below_absolute = status->reclaimable_bytes < absolute_threshold; - uint64_t quotient = status->database_bytes / 10ULL; - uint64_t remainder = status->database_bytes % 10ULL; - int below_ratio = status->reclaimable_bytes < quotient || - (status->reclaimable_bytes == quotient && remainder != 0); - return below_absolute && below_ratio; -} - -typedef struct { - const char *operation; - const char *after_temp_verification; - const char *after_live_to_recovery; - const char *after_temp_to_live; - const char *before_recovery_cleanup; - bool remove_sidecars; - bool require_source_verified; - bool keep_recovery; -} operations_replacement_options_t; - -/* Complete or resume a verified same-directory database replacement. */ -static int operations_replace_verified_database( - const char *live, const char *temporary, const char *recovery, const char *parent, - const operations_replacement_options_t *options, int *out_schema_version, - cbm_zova_operation_report_t *out) { - int live_state = operations_restore_path_state(live); - int temp_state = operations_restore_path_state(temporary); - int recovery_state = operations_restore_path_state(recovery); - int valid_state = (live_state == 1 && temp_state == 1 && recovery_state == 0) || - (live_state == 0 && temp_state == 1 && recovery_state == 1) || - (live_state == 1 && temp_state == 0 && recovery_state == 1); - if (live_state < 0 || temp_state < 0 || recovery_state < 0 || !valid_state) - return -1; - - int schema_version = 0; - if (live_state == 1 && temp_state == 1 && recovery_state == 0) { - if (operations_verify_database(temporary, &schema_version) != 0) - return -1; - if (operations_replacement_fault(options->operation, - options->after_temp_verification, out)) - return 1; - if ((options->require_source_verified && - operations_verify_database(live, NULL) != 0) || - (options->remove_sidecars && - (operations_remove_closed_database_sidecars(live) != 0 || - operations_remove_closed_database_sidecars(temporary) != 0)) || - rename(live, recovery) != 0 || operations_sync_directory(parent) != 0) - return -1; - live_state = 0; - recovery_state = 1; - if (operations_replacement_fault(options->operation, - options->after_live_to_recovery, out)) - return 1; - } - if (live_state == 0 && temp_state == 1 && recovery_state == 1) { - if (operations_verify_database(temporary, &schema_version) != 0 || - (options->require_source_verified && - operations_verify_database(recovery, NULL) != 0) || - (options->remove_sidecars && - (operations_remove_closed_database_sidecars(temporary) != 0 || - operations_remove_closed_database_sidecars(recovery) != 0 || - operations_remove_closed_database_sidecars(live) != 0)) || - rename(temporary, live) != 0 || operations_sync_directory(parent) != 0) - return -1; - live_state = 1; - temp_state = 0; - if (operations_replacement_fault(options->operation, - options->after_temp_to_live, out)) - return 1; - } - if (live_state != 1 || temp_state != 0 || recovery_state != 1 || - operations_verify_database(live, &schema_version) != 0) - return -1; - if (operations_replacement_fault(options->operation, - options->before_recovery_cleanup, out)) - return 1; - if (!options->keep_recovery && - ((options->remove_sidecars && - operations_remove_closed_database_sidecars(recovery) != 0) || - cbm_unlink(recovery) != 0 || operations_sync_directory(parent) != 0)) - return -1; - if (out_schema_version) *out_schema_version = schema_version; - return 0; -} -#endif - -cbm_zova_operation_code_t cbm_zova_database_compact( - const char *path, cbm_zova_operation_report_t *out) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "compact", "invalid_argument"); - if (!path || !path[0] || !out) return CBM_ZOVA_OPERATION_INVALID; -#if !CBM_WITH_ZOVA - return CBM_ZOVA_OPERATION_INCOMPATIBLE; -#else - size_t path_len = strlen(path); - char temporary[4096], recovery[4096], parent[4096]; - if (path_len <= 5 || strcmp(path + path_len - 5, ".zova") != 0 || path_len - 5 > 4000 || - snprintf(temporary, sizeof(temporary), "%.*s.compact.tmp.zova", - (int)(path_len - 5), path) >= (int)sizeof(temporary) || - snprintf(recovery, sizeof(recovery), "%.*s.compact.recovery.zova", - (int)(path_len - 5), path) >= (int)sizeof(recovery) || - operations_parent_directory(path, parent, sizeof(parent)) != 0) - return CBM_ZOVA_OPERATION_INVALID; - - cbm_zova_writer_guard_t guard = {0}; - if (cbm_zova_writer_guard_try_acquire(path, &guard) != 0) { - operations_report(out, CBM_ZOVA_OPERATION_BUSY, "compact", "writer_busy"); - return CBM_ZOVA_OPERATION_BUSY; - } - struct timespec started = {0}, finished = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - int live_state = operations_restore_path_state(path); - int temp_state = operations_restore_path_state(temporary); - int recovery_state = operations_restore_path_state(recovery); - int valid_state = (live_state == 1 && temp_state == 0 && recovery_state == 0) || - (live_state == 1 && temp_state == 1 && recovery_state == 0) || - (live_state == 0 && temp_state == 1 && recovery_state == 1) || - (live_state == 1 && temp_state == 0 && recovery_state == 1); - if (live_state < 0 || temp_state < 0 || recovery_state < 0 || !valid_state) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "compact", - "invalid_recovery_state"); - cbm_zova_writer_guard_release(&guard); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - - cbm_zova_operation_report_t before = {0}; - if (live_state == 1 && temp_state == 0 && recovery_state == 0) { - cbm_zova_operation_code_t status_code = cbm_zova_database_status(path, &before); - if (status_code != CBM_ZOVA_OPERATION_OK) { - operations_report(out, status_code, "compact", "status_failed"); - cbm_zova_writer_guard_release(&guard); - return status_code; - } - if (operations_compact_below_threshold(&before)) { - operations_report(out, CBM_ZOVA_OPERATION_NOOP, "compact", "below_threshold"); - operations_copy_status_metrics(out, &before); - out->reclaimable_bytes = before.reclaimable_bytes; - cbm_zova_writer_guard_release(&guard); - return CBM_ZOVA_OPERATION_NOOP; - } - const char *free_override = getenv("CBM_ZOVA_TEST_COMPACT_FREE_BYTES"); - if (free_override && free_override[0]) { - unsigned long long overridden = 0; - char trailing = '\0'; - if (sscanf(free_override, "%llu%c", &overridden, &trailing) != 1) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "compact", - "invalid_test_free_bytes"); - cbm_zova_writer_guard_release(&guard); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - before.free_bytes = (uint64_t)overridden; - } - const uint64_t reserve = 1ULL * 1024ULL * 1024ULL * 1024ULL; - const uint64_t guard_bytes = 8ULL * 1024ULL * 1024ULL * 1024ULL; - if (before.database_bytes > UINT64_MAX - reserve) { - operations_report(out, CBM_ZOVA_OPERATION_DISK_REFUSED, "compact", - "required_space_overflow"); - cbm_zova_writer_guard_release(&guard); - return CBM_ZOVA_OPERATION_DISK_REFUSED; - } - uint64_t required = before.database_bytes + reserve; - if (required < guard_bytes) required = guard_bytes; - if (before.free_bytes < required) { - operations_report(out, CBM_ZOVA_OPERATION_DISK_REFUSED, "compact", - "insufficient_disk"); - operations_copy_status_metrics(out, &before); - out->reclaimable_bytes = before.reclaimable_bytes; - cbm_zova_writer_guard_release(&guard); - return CBM_ZOVA_OPERATION_DISK_REFUSED; - } - - zova_database *db = NULL; - zova_message error = {0}; - int opened = zova_database_open_with_options(&(zova_database_open_options_request){ - .path = path, .flags = 0, .busy_timeout_ms = 0, - .out_db = &db, .out_error_message = &error}) == ZOVA_OK && db; - zova_message_free(&error); - int compacted = opened && operations_checkpoint_truncate(db) == 0 && - zova_database_compact(&(zova_database_compact_request){ - .db = db, .destination_path = temporary, .flags = 0}) == ZOVA_OK; - if (db) (void)zova_database_close(db); - if (!compacted) { - (void)operations_remove_closed_database_sidecars(temporary); - (void)cbm_unlink(temporary); - operations_report(out, CBM_ZOVA_OPERATION_BUSY, "compact", - "checkpoint_or_compact_busy"); - cbm_zova_writer_guard_release(&guard); - return CBM_ZOVA_OPERATION_BUSY; - } - if (operations_remove_closed_database_sidecars(path) != 0 || - operations_remove_closed_database_sidecars(temporary) != 0) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "compact", - "sidecar_cleanup_failed"); - cbm_zova_writer_guard_release(&guard); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - temp_state = 1; - if (operations_replacement_fault("compact", "compact_after_temp_creation", out)) - goto interrupted; - } - - int schema_version = 0; - const operations_replacement_options_t replacement = { - .operation = "compact", - .after_temp_verification = "compact_after_temp_verification", - .after_live_to_recovery = "compact_after_live_to_recovery", - .after_temp_to_live = "compact_after_temp_to_live", - .before_recovery_cleanup = "compact_before_recovery_cleanup", - .remove_sidecars = true, - .require_source_verified = true, - }; - int replacement_result = operations_replace_verified_database( - path, temporary, recovery, parent, &replacement, &schema_version, out); - if (replacement_result > 0) goto interrupted; - if (replacement_result < 0) goto replacement_failed; - - cbm_zova_operation_report_t after = {0}; - if (cbm_zova_database_status(path, &after) != CBM_ZOVA_OPERATION_OK) - goto replacement_failed; - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - operations_report(out, CBM_ZOVA_OPERATION_OK, "compact", "ok"); - operations_copy_status_metrics(out, &after); - out->reclaimable_bytes = after.reclaimable_bytes; - out->elapsed_ms = ((double)(finished.tv_sec - started.tv_sec) * 1000.0) + - ((double)(finished.tv_nsec - started.tv_nsec) / 1000000.0); - cbm_zova_writer_guard_release(&guard); - return CBM_ZOVA_OPERATION_OK; - -interrupted: - cbm_zova_writer_guard_release(&guard); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - -replacement_failed: - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "compact", - "replacement_failed"); - cbm_zova_writer_guard_release(&guard); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; -#endif -} - -/* Zova 1.2.0 stages a migration in ".migration-recovery/" and - * removes it on success, but for a WAL database the staged copy's empty -wal - * and -shm stay behind and keep the directory alive. Remove exactly those. */ -static void operations_remove_migration_staging(const char *destination) { - const char *name = strrchr(destination, '/'); - name = name ? name + 1 : destination; - char directory[4096 + 32], sidecar[sizeof(directory) + 512]; - snprintf(directory, sizeof(directory), "%s.migration-recovery", destination); - snprintf(sidecar, sizeof(sidecar), "%s/%s-wal", directory, name); - (void)cbm_unlink(sidecar); - snprintf(sidecar, sizeof(sidecar), "%s/%s-shm", directory, name); - (void)cbm_unlink(sidecar); - (void)cbm_rmdir(directory); -} - -cbm_zova_operation_code_t cbm_zova_database_migrate_format( - const char *path, cbm_zova_operation_report_t *out) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "migrate-format", "invalid_argument"); - if (!path || !path[0] || !out) return CBM_ZOVA_OPERATION_INVALID; -#if !CBM_WITH_ZOVA - return CBM_ZOVA_OPERATION_INCOMPATIBLE; -#else - size_t path_len = strlen(path); - char temporary[4096], recovery[4096], parent[4096]; - if (path_len <= 5 || strcmp(path + path_len - 5, ".zova") != 0 || path_len - 5 > 4000 || - snprintf(temporary, sizeof(temporary), "%.*s.format.tmp.zova", - (int)(path_len - 5), path) >= (int)sizeof(temporary) || - snprintf(recovery, sizeof(recovery), "%.*s.format.recovery.zova", - (int)(path_len - 5), path) >= (int)sizeof(recovery) || - operations_parent_directory(path, parent, sizeof(parent)) != 0) - return CBM_ZOVA_OPERATION_INVALID; - - cbm_zova_writer_guard_t guard = {0}; - if (cbm_zova_writer_guard_try_acquire(path, &guard) != 0) { - operations_report(out, CBM_ZOVA_OPERATION_BUSY, "migrate-format", "writer_busy"); - return CBM_ZOVA_OPERATION_BUSY; - } - cbm_zova_operation_code_t code = CBM_ZOVA_OPERATION_VERIFY_FAILED; - const char *reason = "invalid_recovery_state"; - int live_state = operations_restore_path_state(path); - int temp_state = operations_restore_path_state(temporary); - int recovery_state = operations_restore_path_state(recovery); - int valid_state = (live_state == 1 && temp_state == 0 && recovery_state == 0) || - (live_state == 1 && temp_state == 1 && recovery_state == 0) || - (live_state == 0 && temp_state == 1 && recovery_state == 1) || - (live_state == 1 && temp_state == 0 && recovery_state == 1); - if (live_state < 0 || temp_state < 0 || recovery_state < 0 || !valid_state) goto done; - - if (live_state == 1 && temp_state == 0 && recovery_state == 0) { - cbm_zova_database_format_status_t format = cbm_zova_database_format_status(path); - if (format == CBM_ZOVA_DATABASE_COMPATIBLE) { - code = CBM_ZOVA_OPERATION_NOOP; - reason = "current_format"; - goto done; - } - if (format != CBM_ZOVA_DATABASE_MIGRATION_REQUIRED) { - code = CBM_ZOVA_OPERATION_INCOMPATIBLE; - reason = "unsupported_format"; - goto done; - } - /* Zova writes the migrated copy next to the source, so the directory - * briefly holds both files. */ - struct stat source_stat = {0}; - uint64_t free_bytes = 0; - const uint64_t reserve = 1ULL * 1024ULL * 1024ULL * 1024ULL; - if (cbm_lstat(path, &source_stat) != 0 || source_stat.st_size < 0 || - operations_free_bytes(path, &free_bytes) != 0) { - reason = "free_space_unavailable"; - goto done; - } - if (free_bytes < (uint64_t)source_stat.st_size + reserve) { - code = CBM_ZOVA_OPERATION_DISK_REFUSED; - reason = "insufficient_disk"; - goto done; - } - /* The replacement drops the source sidecars, so the WAL must be empty. */ - if (operations_raw_sqlite_checkpoint_truncate(path) != 0) { - code = CBM_ZOVA_OPERATION_BUSY; - reason = "checkpoint_busy"; - goto done; - } - zova_message error = {0}; - zova_status migrated = zova_database_migrate(&(zova_database_migrate_request){ - .source_path = path, .destination_path = temporary, .flags = 0, - .out_error_message = &error}); - if (migrated != ZOVA_OK) { - cbm_log_error("zova.migrate_format", "path", path, "error", - error.data ? (const char *)error.data : "unknown"); - zova_message_free(&error); - (void)operations_remove_closed_database_sidecars(temporary); - (void)cbm_unlink(temporary); - reason = "zova_migrate_failed"; - goto done; - } - zova_message_free(&error); - operations_remove_migration_staging(temporary); - if (operations_replacement_fault("migrate-format", "migrate_format_after_temp_creation", - out)) - goto interrupted; - } - - const operations_replacement_options_t replacement = { - .operation = "migrate-format", - .after_temp_verification = "migrate_format_after_temp_verification", - .after_live_to_recovery = "migrate_format_after_live_to_recovery", - .after_temp_to_live = "migrate_format_after_temp_to_live", - .before_recovery_cleanup = "migrate_format_before_recovery_cleanup", - .remove_sidecars = true, - /* The current SDK cannot open the old-format source to verify it. */ - .require_source_verified = false, - }; - int replacement_result = operations_replace_verified_database( - path, temporary, recovery, parent, &replacement, NULL, out); - if (replacement_result > 0) goto interrupted; - if (replacement_result < 0) { - reason = "replacement_failed"; - goto done; - } - cbm_zova_operation_report_t after = {0}; - if (cbm_zova_database_status(path, &after) != CBM_ZOVA_OPERATION_OK) { - reason = "replacement_failed"; - goto done; - } - operations_report(out, CBM_ZOVA_OPERATION_OK, "migrate-format", "ok"); - operations_copy_status_metrics(out, &after); - cbm_zova_writer_guard_release(&guard); - return CBM_ZOVA_OPERATION_OK; - -done: - operations_report(out, code, "migrate-format", reason); -interrupted: - cbm_zova_writer_guard_release(&guard); - return out->code; -#endif -} - -#if CBM_WITH_ZOVA -static int operations_workspace_health_row(zova_database *db, const char *workspace_id, - int64_t *out_generation, int *out_rebuild) { - zova_statement *statement = NULL; - if (!db || !workspace_id || !out_generation || !out_rebuild || - zova_database_prepare(&(zova_database_prepare_request){ - .db = db, - .sql = "SELECT r.active_generation,COALESCE(h.state,'healthy') " - "FROM cbm_workspace_registry r LEFT JOIN cbm_workspace_health_v1 h " - "ON h.workspace_key=r.workspace_key WHERE r.workspace_id=?1", - .out_statement = &statement}) != ZOVA_OK || !statement || - zova_statement_bind_text(&(zova_statement_bind_text_request){ - .statement = statement, .index = 1, .data = (const uint8_t *)workspace_id, - .len = strlen(workspace_id)}) != ZOVA_OK) { - if (statement) (void)zova_statement_finalize(statement); - return -1; - } - zova_step_result step = ZOVA_STEP_DONE; - zova_text state = {0}; - int rc = operations_step(statement, &step) == 0 && step == ZOVA_STEP_ROW && - zova_statement_column_int64(&(zova_statement_column_int64_request){ - .statement = statement, .index = 0, - .out_value = out_generation}) == ZOVA_OK && - zova_statement_column_text(&(zova_statement_column_text_request){ - .statement = statement, .index = 1, .out_text = &state}) == ZOVA_OK && - state.data - ? 0 : -1; - if (rc == 0) - *out_rebuild = state.len == strlen("rebuild_required") && - memcmp(state.data, "rebuild_required", state.len) == 0; - zova_text_free(&state); - (void)zova_statement_finalize(statement); - return rc; -} - -static int operations_mark_workspace_rebuild_required(const char *path, - const char *workspace_id, - int64_t generation, - const char *reason) { - cbm_zova_writer_guard_t guard = {0}; - if (cbm_zova_writer_guard_acquire(path, &guard) != 0) return -1; - zova_database *db = NULL; - zova_message error = {0}; - int rc = zova_database_open(&(zova_database_open_request){ - .path = path, .out_db = &db, .out_error_message = &error}) == ZOVA_OK && db - ? 0 : -1; - zova_message_free(&error); - if (rc == 0 && - zova_database_begin_immediate(&(zova_database_simple_request){.db = db}) != ZOVA_OK) - rc = -1; - zova_statement *statement = NULL; - if (rc == 0 && - (zova_database_prepare(&(zova_database_prepare_request){ - .db = db, - .sql = "INSERT INTO cbm_workspace_health_v1" - "(workspace_key,state,reason,checked_generation,checked_at) " - "SELECT workspace_key,'rebuild_required',?2,?3," - "strftime('%Y-%m-%dT%H:%M:%fZ','now') " - "FROM cbm_workspace_registry WHERE workspace_id=?1 " - "ON CONFLICT(workspace_key) DO UPDATE SET " - "state=excluded.state,reason=excluded.reason," - "checked_generation=excluded.checked_generation," - "checked_at=excluded.checked_at", - .out_statement = &statement}) != ZOVA_OK || !statement)) - rc = -1; - if (rc == 0 && - zova_statement_bind_text(&(zova_statement_bind_text_request){ - .statement = statement, .index = 1, .data = (const uint8_t *)workspace_id, - .len = strlen(workspace_id)}) != ZOVA_OK) - rc = -1; - if (rc == 0 && - zova_statement_bind_text(&(zova_statement_bind_text_request){ - .statement = statement, .index = 2, .data = (const uint8_t *)reason, - .len = strlen(reason)}) != ZOVA_OK) - rc = -1; - if (rc == 0 && - zova_statement_bind_int64(&(zova_statement_bind_int64_request){ - .statement = statement, .index = 3, .value = generation}) != ZOVA_OK) - rc = -1; - zova_step_result step = ZOVA_STEP_ROW; - if (rc == 0 && (operations_step(statement, &step) != 0 || step != ZOVA_STEP_DONE)) rc = -1; - if (statement) (void)zova_statement_finalize(statement); - if (db) { - if (rc == 0) { - if (zova_database_commit(&(zova_database_simple_request){.db = db}) != ZOVA_OK) - rc = -1; - } else { - (void)zova_database_rollback(&(zova_database_simple_request){.db = db}); - } - (void)zova_database_close(db); - } - cbm_zova_writer_guard_release(&guard); - return rc; -} - -static int operations_workspace_project(const char *path, const char *workspace_id, - char *out, size_t out_size) { - zova_database *db = NULL; - zova_message error = {0}; - zova_statement *statement = NULL; - int rc = zova_database_open_with_options(&(zova_database_open_options_request){ - .path = path, .flags = ZOVA_OPEN_READ_ONLY, .busy_timeout_ms = 5000, - .out_db = &db, .out_error_message = &error}) == ZOVA_OK && db - ? 0 : -1; - zova_message_free(&error); - if (rc == 0 && - (zova_database_prepare(&(zova_database_prepare_request){ - .db = db, - .sql = "SELECT p.project FROM cbm_projects_v1 p " - "JOIN cbm_workspace_registry r USING(workspace_key) " - "WHERE r.workspace_id=?1", - .out_statement = &statement}) != ZOVA_OK || !statement)) - rc = -1; - if (rc == 0 && - zova_statement_bind_text(&(zova_statement_bind_text_request){ - .statement = statement, .index = 1, .data = (const uint8_t *)workspace_id, - .len = strlen(workspace_id)}) != ZOVA_OK) - rc = -1; - zova_step_result step = ZOVA_STEP_DONE; - zova_text project = {0}; - if (rc == 0 && - (operations_step(statement, &step) != 0 || step != ZOVA_STEP_ROW || - zova_statement_column_text(&(zova_statement_column_text_request){ - .statement = statement, .index = 0, .out_text = &project}) != ZOVA_OK || - !project.data || project.len == 0 || project.len >= out_size)) - rc = -1; - if (rc == 0) { - memcpy(out, project.data, project.len); - out[project.len] = '\0'; - } - zova_text_free(&project); - if (statement) (void)zova_statement_finalize(statement); - if (db) (void)zova_database_close(db); - return rc; -} - -static int operations_quarantine_database(const char *path) { - size_t length = strlen(path); - char quarantine[4096], parent[4096]; - if (length <= 5 || strcmp(path + length - 5, ".zova") != 0 || length - 5 > 4000 || - snprintf(quarantine, sizeof(quarantine), "%.*s.corrupt.zova", - (int)(length - 5), path) >= (int)sizeof(quarantine) || - operations_parent_directory(path, parent, sizeof(parent)) != 0 || - operations_restore_path_state(path) != 1 || - operations_restore_path_state(quarantine) != 0) - return -1; - char source_sidecars[2][4096], quarantine_sidecars[2][4096]; - const char *suffixes[] = {"-wal", "-shm"}; - int present[2] = {0, 0}; - for (size_t i = 0; i < 2; i++) { - if (snprintf(source_sidecars[i], sizeof(source_sidecars[i]), "%s%s", path, - suffixes[i]) >= (int)sizeof(source_sidecars[i]) || - snprintf(quarantine_sidecars[i], sizeof(quarantine_sidecars[i]), "%s%s", - quarantine, suffixes[i]) >= (int)sizeof(quarantine_sidecars[i])) - return -1; - int source_state = operations_restore_path_state(source_sidecars[i]); - int quarantine_state = operations_restore_path_state(quarantine_sidecars[i]); - if (source_state < 0 || quarantine_state != 0) return -1; - present[i] = source_state == 1; - } - cbm_zova_writer_guard_t guard = {0}; - if (cbm_zova_writer_guard_acquire(path, &guard) != 0) return -1; - int moved[2] = {0, 0}; - int rc = 0; - for (size_t i = 0; i < 2; i++) { - if (present[i] && rename(source_sidecars[i], quarantine_sidecars[i]) != 0) { - rc = -1; - break; - } - if (present[i]) moved[i] = 1; - } - if (rc == 0 && rename(path, quarantine) != 0) rc = -1; - if (rc == 0 && operations_sync_directory(parent) != 0) rc = -1; - if (rc != 0) { - for (int i = 1; i >= 0; i--) - if (moved[i]) (void)rename(quarantine_sidecars[i], source_sidecars[i]); - } - cbm_zova_writer_guard_release(&guard); - return rc; -} -#endif - -cbm_zova_operation_code_t cbm_zova_database_health( - const char *path, const char *workspace_id, cbm_zova_health_class_t *out_class, - cbm_zova_operation_report_t *out) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "health", "invalid_argument"); - if (!path || !path[0] || !out_class || !out || - (workspace_id && cbm_zova_workspace_id_validate(workspace_id) != 0)) - return CBM_ZOVA_OPERATION_INVALID; -#if !CBM_WITH_ZOVA - return CBM_ZOVA_OPERATION_INCOMPATIBLE; -#else - *out_class = CBM_ZOVA_HEALTH_WHOLE_FILE_RECOVERY; - zova_database *db = NULL; - zova_message error = {0}; - zova_status open_status = zova_database_open_with_options(&(zova_database_open_options_request){ - .path = path, .flags = ZOVA_OPEN_READ_ONLY, .busy_timeout_ms = 5000, - .out_db = &db, .out_error_message = &error}); - int opened = open_status == ZOVA_OK && db; - zova_message_free(&error); - zova_status check_status = ZOVA_OK; - int64_t schema_version = 0; - int shared_ok = opened && operations_quick_check_status(db, &check_status) == 0 && - operations_query_i64( - db, "SELECT schema_version FROM cbm_database_schema_v1 WHERE id=1", - &schema_version) == 0 && - schema_version == CBM_ZOVA_DATABASE_SCHEMA_VERSION && - cbm_zova_user_database_schema_is_current(db) == 0; - if (!shared_ok) { - if (db) (void)zova_database_close(db); - if (operations_status_is_transient(opened ? check_status : open_status)) { - /* Unreadable right now is not damage; never send it to recovery. */ - operations_report(out, CBM_ZOVA_OPERATION_BUSY, "health", "database_unavailable"); - return CBM_ZOVA_OPERATION_BUSY; - } - operations_report(out, CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED, "health", - "shared_database_invalid"); - out->schema_version = (int)schema_version; - return CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED; - } - if (operations_foreign_keys_ok(db) != 0) { - (void)zova_database_close(db); - operations_report(out, CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED, "health", - "foreign_key_violation"); - out->schema_version = (int)schema_version; - return CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED; - } - if (!workspace_id) { - int64_t workspace_count = 0; - if (operations_query_i64(db, "SELECT count(*) FROM cbm_workspace_registry", - &workspace_count) != 0 || workspace_count < 0 || - (uint64_t)workspace_count > SIZE_MAX / CBM_ZOVA_WORKSPACE_ID_MAX) { - (void)zova_database_close(db); - operations_report(out, CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED, "health", - "workspace_inventory_invalid"); - return CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED; - } - char(*workspace_ids)[CBM_ZOVA_WORKSPACE_ID_MAX] = - workspace_count > 0 ? calloc((size_t)workspace_count, sizeof(*workspace_ids)) : NULL; - zova_statement *statement = NULL; - int inventory_ok = workspace_count == 0 || - (workspace_ids && - zova_database_prepare(&(zova_database_prepare_request){ - .db = db, - .sql = "SELECT workspace_id FROM cbm_workspace_registry ORDER BY workspace_id", - .out_statement = &statement}) == ZOVA_OK && statement); - for (int64_t i = 0; inventory_ok && i < workspace_count; i++) { - zova_step_result step = ZOVA_STEP_DONE; - zova_text value = {0}; - inventory_ok = operations_step(statement, &step) == 0 && step == ZOVA_STEP_ROW && - zova_statement_column_text(&(zova_statement_column_text_request){ - .statement = statement, .index = 0, .out_text = &value}) == - ZOVA_OK && value.data && value.len > 0 && - value.len < CBM_ZOVA_WORKSPACE_ID_MAX; - if (inventory_ok) { - memcpy(workspace_ids[i], value.data, value.len); - workspace_ids[i][value.len] = '\0'; - } - zova_text_free(&value); - } - if (statement) (void)zova_statement_finalize(statement); - (void)zova_database_close(db); - if (!inventory_ok) { - free(workspace_ids); - operations_report(out, CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED, "health", - "workspace_inventory_invalid"); - return CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED; - } - for (int64_t i = 0; i < workspace_count; i++) { - cbm_zova_health_class_t workspace_health = CBM_ZOVA_HEALTH_OK; - cbm_zova_operation_report_t workspace_report = {0}; - cbm_zova_operation_code_t code = cbm_zova_database_health( - path, workspace_ids[i], &workspace_health, &workspace_report); - if (code != CBM_ZOVA_OPERATION_OK) { - free(workspace_ids); - *out = workspace_report; - *out_class = workspace_health; - return code; - } - } - free(workspace_ids); - *out_class = CBM_ZOVA_HEALTH_OK; - operations_report(out, CBM_ZOVA_OPERATION_OK, "health", "ok"); - out->schema_version = (int)schema_version; - return CBM_ZOVA_OPERATION_OK; - } - int64_t generation = 0; - int already_rebuild = 0; - if (operations_workspace_health_row(db, workspace_id, &generation, &already_rebuild) != 0) { - (void)zova_database_close(db); - operations_report(out, CBM_ZOVA_OPERATION_WORKSPACE_REBUILD_REQUIRED, "health", - "workspace_generation_invalid"); - snprintf(out->workspace_id, sizeof(out->workspace_id), "%s", workspace_id); - *out_class = CBM_ZOVA_HEALTH_WORKSPACE_REBUILD; - return CBM_ZOVA_OPERATION_WORKSPACE_REBUILD_REQUIRED; - } - (void)zova_database_close(db); - if (already_rebuild) { - operations_report(out, CBM_ZOVA_OPERATION_WORKSPACE_REBUILD_REQUIRED, "health", - "workspace_rebuild_required"); - snprintf(out->workspace_id, sizeof(out->workspace_id), "%s", workspace_id); - out->generation = generation; - out->schema_version = (int)schema_version; - *out_class = CBM_ZOVA_HEALTH_WORKSPACE_REBUILD; - return CBM_ZOVA_OPERATION_WORKSPACE_REBUILD_REQUIRED; - } - cbm_zova_workspace_snapshot_t snapshot = {0}; - if (cbm_zova_repository_export_snapshot(path, workspace_id, &snapshot) != 0) { - if (operations_mark_workspace_rebuild_required( - path, workspace_id, generation, "workspace_public_state_invalid") != 0) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "health", - "health_state_write_failed"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - operations_report(out, CBM_ZOVA_OPERATION_WORKSPACE_REBUILD_REQUIRED, "health", - "workspace_public_state_invalid"); - snprintf(out->workspace_id, sizeof(out->workspace_id), "%s", workspace_id); - out->generation = generation; - out->schema_version = (int)schema_version; - *out_class = CBM_ZOVA_HEALTH_WORKSPACE_REBUILD; - return CBM_ZOVA_OPERATION_WORKSPACE_REBUILD_REQUIRED; - } - const cbm_zova_workspace_generation_input_t input = { - .root_path = snapshot.root_path, - .project = snapshot.project, - .indexed_at = snapshot.indexed_at, - .model_fingerprint = snapshot.model_fingerprint, - .vector_dimensions = snapshot.vector_dimensions, - .nodes = snapshot.nodes, - .node_count = snapshot.node_count, - .edges = snapshot.edges, - .edge_count = snapshot.edge_count, - .node_vectors = snapshot.node_vectors, - .node_vector_count = snapshot.node_vector_count, - .token_vectors = snapshot.token_vectors, - .token_vector_count = snapshot.token_vector_count, - .file_hashes = snapshot.file_hashes, - .file_hash_count = snapshot.file_hash_count, - .project_summary = snapshot.project_summary, - }; - cbm_zova_workspace_generation_result_t readback = {0}; - int digest_ok = cbm_zova_workspace_generation_digest_input( - workspace_id, &input, &readback) == 0; - if (digest_ok) { - readback.generation = snapshot.generation; - snprintf(readback.workspace_id, sizeof(readback.workspace_id), "%s", workspace_id); - digest_ok = operations_generation_matches(&readback, &snapshot.integrity); - } - cbm_zova_workspace_snapshot_free(&snapshot); - if (!digest_ok) { - if (operations_mark_workspace_rebuild_required( - path, workspace_id, generation, "workspace_digest_mismatch") != 0) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "health", - "health_state_write_failed"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - operations_report(out, CBM_ZOVA_OPERATION_WORKSPACE_REBUILD_REQUIRED, "health", - "workspace_digest_mismatch"); - snprintf(out->workspace_id, sizeof(out->workspace_id), "%s", workspace_id); - out->generation = generation; - out->schema_version = (int)schema_version; - *out_class = CBM_ZOVA_HEALTH_WORKSPACE_REBUILD; - return CBM_ZOVA_OPERATION_WORKSPACE_REBUILD_REQUIRED; - } - operations_report(out, CBM_ZOVA_OPERATION_OK, "health", "ok"); - snprintf(out->workspace_id, sizeof(out->workspace_id), "%s", workspace_id); - out->generation = generation; - out->schema_version = (int)schema_version; - *out_class = CBM_ZOVA_HEALTH_OK; - return CBM_ZOVA_OPERATION_OK; -#endif -} - -static cbm_zova_operation_code_t cbm_zova_workspace_recover_body( - const char *path, const char *workspace_id, const char *repo_path, - cbm_zova_operation_report_t *out) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "recover_workspace", - "invalid_argument"); - if (!path || !path[0] || cbm_zova_workspace_id_validate(workspace_id) != 0 || - !repo_path || !repo_path[0] || !out) - return CBM_ZOVA_OPERATION_INVALID; -#if !CBM_WITH_ZOVA - return CBM_ZOVA_OPERATION_INCOMPATIBLE; -#else - char expected_workspace[CBM_ZOVA_WORKSPACE_ID_MAX]; - char user_path[4096]; - if (cbm_zova_workspace_id_for_root(repo_path, expected_workspace, - sizeof(expected_workspace)) != 0 || - strcmp(expected_workspace, workspace_id) != 0 || - cbm_zova_user_database_path(user_path, sizeof(user_path)) != 0 || - strcmp(user_path, path) != 0) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "recover_workspace", - "identity_or_route_mismatch"); - return CBM_ZOVA_OPERATION_INVALID; - } - cbm_zova_health_class_t health = CBM_ZOVA_HEALTH_OK; - cbm_zova_operation_report_t health_report = {0}; - cbm_zova_operation_code_t health_code = - cbm_zova_database_health(path, workspace_id, &health, &health_report); - if (health_code == CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED && - health == CBM_ZOVA_HEALTH_WHOLE_FILE_RECOVERY) { - /* Moving the file aside needs the exclusive barrier, which the caller - * takes after releasing this run's shared hold. */ - operations_report(out, CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED, - "recover_workspace", "whole_file_recovery_required"); - snprintf(out->workspace_id, sizeof(out->workspace_id), "%s", workspace_id); - return CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED; - } - if (health_code != CBM_ZOVA_OPERATION_WORKSPACE_REBUILD_REQUIRED || - health != CBM_ZOVA_HEALTH_WORKSPACE_REBUILD) { - operations_report(out, - health_code == CBM_ZOVA_OPERATION_OK - ? CBM_ZOVA_OPERATION_NOOP : health_code, - "recover_workspace", - health_code == CBM_ZOVA_OPERATION_OK ? "workspace_healthy" - : health_report.reason); - return out->code; - } - char project[256]; - cbm_pipeline_t *pipeline = NULL; - if (operations_workspace_project(path, workspace_id, project, sizeof(project)) != 0 || - !(pipeline = cbm_pipeline_new(repo_path, NULL, CBM_MODE_FULL)) || - !cbm_pipeline_set_project_name(pipeline, project)) { - if (pipeline) cbm_pipeline_free(pipeline); - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "recover_workspace", - "pipeline_create_failed"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - int run_rc = cbm_pipeline_run(pipeline); - cbm_pipeline_free(pipeline); - if (run_rc != 0) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "recover_workspace", - "source_rebuild_failed"); - snprintf(out->workspace_id, sizeof(out->workspace_id), "%s", workspace_id); - out->generation = health_report.generation; - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - health_code = cbm_zova_database_health(path, workspace_id, &health, &health_report); - if (health_code != CBM_ZOVA_OPERATION_OK || health != CBM_ZOVA_HEALTH_OK) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "recover_workspace", - "post_rebuild_health_failed"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - operations_report(out, CBM_ZOVA_OPERATION_OK, "recover_workspace", "ok"); - snprintf(out->workspace_id, sizeof(out->workspace_id), "%s", workspace_id); - out->generation = health_report.generation; - out->schema_version = health_report.schema_version; - return CBM_ZOVA_OPERATION_OK; -#endif -} - -cbm_zova_operation_code_t cbm_zova_database_backup( - const char *source_path, const char *destination_path, - cbm_zova_operation_report_t *out) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "backup", "invalid_argument"); - if (!source_path || !source_path[0] || !destination_path || !destination_path[0] || !out || - strcmp(source_path, destination_path) == 0) { - return CBM_ZOVA_OPERATION_INVALID; - } -#if !CBM_WITH_ZOVA - return CBM_ZOVA_OPERATION_INCOMPATIBLE; -#else - struct stat source_stat; - struct stat destination_stat; - if (!operations_path_is_regular_nosymlink(source_path) || - cbm_lstat(destination_path, &destination_stat) == 0) { - return CBM_ZOVA_OPERATION_INVALID; - } - - struct timespec started = {0}, finished = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - zova_database *source = NULL; - zova_message error = {0}; - if (zova_database_open_with_options(&(zova_database_open_options_request){ - .path = source_path, .flags = ZOVA_OPEN_READ_ONLY, .busy_timeout_ms = 5000, - .out_db = &source, .out_error_message = &error}) != ZOVA_OK || - !source) { - zova_message_free(&error); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - zova_message_free(&error); - int backup_ok = zova_database_backup(&(zova_database_backup_request){ - .db = source, .destination_path = destination_path, .flags = 0}) == ZOVA_OK; - (void)zova_database_close(source); - int schema_version = 0; - if (!backup_ok || operations_verify_database(destination_path, &schema_version) != 0 || - stat(destination_path, &destination_stat) != 0) { - (void)cbm_unlink(destination_path); - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "backup", - "verification_failed"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - operations_report(out, CBM_ZOVA_OPERATION_OK, "backup", "ok"); - out->schema_version = schema_version; - out->database_bytes = (uint64_t)destination_stat.st_size; - out->elapsed_ms = ((double)(finished.tv_sec - started.tv_sec) * 1000.0) + - ((double)(finished.tv_nsec - started.tv_nsec) / 1000000.0); - char wal_path[4096]; - if (snprintf(wal_path, sizeof(wal_path), "%s-wal", source_path) < (int)sizeof(wal_path) && - stat(wal_path, &source_stat) == 0) { - out->wal_bytes = (uint64_t)source_stat.st_size; - } - return CBM_ZOVA_OPERATION_OK; -#endif -} - -static cbm_zova_operation_code_t cbm_zova_database_restore_body( - const char *live_path, const char *backup_path, bool confirm_replace, - cbm_zova_operation_report_t *out) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "restore", "invalid_argument"); - if (!live_path || !live_path[0] || !backup_path || !backup_path[0] || !out) { - return CBM_ZOVA_OPERATION_INVALID; - } - if (!confirm_replace) { - operations_report(out, CBM_ZOVA_OPERATION_CONFIRMATION_REQUIRED, "restore", - "confirmation_required"); - return CBM_ZOVA_OPERATION_CONFIRMATION_REQUIRED; - } -#if !CBM_WITH_ZOVA - return CBM_ZOVA_OPERATION_INCOMPATIBLE; -#else - size_t live_path_len = strlen(live_path); - char temporary[4096], recovery[4096], parent[4096]; - if (live_path_len <= 5 || strcmp(live_path + live_path_len - 5, ".zova") != 0 || - live_path_len - 5 > 4000 || - snprintf(temporary, sizeof(temporary), "%.*s.restore.tmp.zova", - (int)(live_path_len - 5), live_path) >= (int)sizeof(temporary) || - snprintf(recovery, sizeof(recovery), "%.*s.restore.recovery.zova", - (int)(live_path_len - 5), live_path) >= (int)sizeof(recovery) || - operations_parent_directory(live_path, parent, sizeof(parent)) != 0 || - strcmp(temporary, live_path) == 0 || strcmp(temporary, backup_path) == 0) { - return CBM_ZOVA_OPERATION_INVALID; - } - if (!operations_path_is_regular_nosymlink(backup_path)) { - return CBM_ZOVA_OPERATION_INVALID; - } - int preflight_live_state = operations_restore_path_state(live_path); - if (preflight_live_state < 0 || - (preflight_live_state == 1 && operations_same_file(live_path, backup_path))) { - return CBM_ZOVA_OPERATION_INVALID; - } - cbm_zova_writer_guard_t guard = {0}; - if (cbm_zova_writer_guard_acquire(live_path, &guard) != 0) { - operations_report(out, CBM_ZOVA_OPERATION_BUSY, "restore", "writer_busy"); - return CBM_ZOVA_OPERATION_BUSY; - } - struct timespec started = {0}, finished = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - int schema_version = 0; - int live_state = operations_restore_path_state(live_path); - int temp_state = operations_restore_path_state(temporary); - int recovery_state = operations_restore_path_state(recovery); - int valid_state = (live_state == 1 && temp_state == 0 && recovery_state == 0) || - (live_state == 1 && temp_state == 1 && recovery_state == 0) || - (live_state == 0 && temp_state == 1 && recovery_state == 1) || - (live_state == 1 && temp_state == 0 && recovery_state == 1); - if (live_state < 0 || temp_state < 0 || recovery_state < 0 || !valid_state || - (temp_state == 1 && operations_same_file(temporary, backup_path)) || - (recovery_state == 1 && operations_same_file(recovery, backup_path)) || - operations_verify_database(backup_path, NULL) != 0) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "restore", - "invalid_recovery_state"); - cbm_zova_writer_guard_release(&guard); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - - if (temp_state == 0 && recovery_state == 0) { - zova_message error = {0}; - int restored = zova_database_restore(&(zova_database_restore_request){ - .source_path = backup_path, .destination_path = temporary, - .flags = 0, .out_error_message = &error}) == ZOVA_OK; - zova_message_free(&error); - if (!restored) goto replacement_failed; - temp_state = 1; - if (operations_replacement_fault("restore", "restore_after_temp_creation", out)) - goto interrupted; - } - const operations_replacement_options_t replacement = { - .operation = "restore", - .after_temp_verification = "restore_after_temp_verification", - .after_live_to_recovery = "restore_after_live_to_recovery", - .after_temp_to_live = "restore_after_temp_to_live", - .before_recovery_cleanup = "restore_before_recovery_cleanup", - /* A hot -wal left by a crashed writer would replay onto the restored file. */ - .remove_sidecars = true, - .require_source_verified = false, - }; - int replacement_result = operations_replace_verified_database( - live_path, temporary, recovery, parent, &replacement, &schema_version, out); - if (replacement_result > 0) goto interrupted; - if (replacement_result < 0) goto replacement_failed; - - struct stat live_stat; - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - operations_report(out, CBM_ZOVA_OPERATION_OK, "restore", "ok"); - out->schema_version = schema_version; - if (stat(live_path, &live_stat) == 0) out->database_bytes = (uint64_t)live_stat.st_size; - out->elapsed_ms = ((double)(finished.tv_sec - started.tv_sec) * 1000.0) + - ((double)(finished.tv_nsec - started.tv_nsec) / 1000000.0); - cbm_zova_writer_guard_release(&guard); - return CBM_ZOVA_OPERATION_OK; - -interrupted: - cbm_zova_writer_guard_release(&guard); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - -replacement_failed: - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "restore", - "replacement_failed"); - cbm_zova_writer_guard_release(&guard); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; -#endif -} - -cbm_zova_operation_code_t cbm_zova_database_export( - const char *source_path, const char *archive_directory, - cbm_zova_operation_report_t *out) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "export_database", "invalid_argument"); - if (!source_path || !source_path[0] || !archive_directory || !archive_directory[0] || !out) - return CBM_ZOVA_OPERATION_INVALID; -#if !CBM_WITH_ZOVA - return CBM_ZOVA_OPERATION_INCOMPATIBLE; -#else - if (!operations_path_is_regular_nosymlink(source_path)) return CBM_ZOVA_OPERATION_INVALID; - char partial[4096], data[4096], manifest_path[4096], parent[4096]; - if (snprintf(partial, sizeof(partial), "%s.partial", archive_directory) >= - (int)sizeof(partial) || - snprintf(data, sizeof(data), "%s/data.zova", partial) >= (int)sizeof(data) || - snprintf(manifest_path, sizeof(manifest_path), "%s/manifest.json", partial) >= - (int)sizeof(manifest_path) || - operations_parent_directory(archive_directory, parent, sizeof(parent)) != 0) - return CBM_ZOVA_OPERATION_INVALID; - struct stat value; - if (cbm_lstat(archive_directory, &value) == 0) return CBM_ZOVA_OPERATION_INVALID; - if (cbm_lstat(partial, &value) == 0) { - operations_report(out, CBM_ZOVA_OPERATION_BUSY, "export_database", "partial_exists"); - return CBM_ZOVA_OPERATION_BUSY; - } - if (cbm_mkdir_mode(partial, 0700) != 0) return CBM_ZOVA_OPERATION_INVALID; - struct timespec started = {0}, finished = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - cbm_zova_operation_report_t backup = {0}; - operations_archive_manifest_t manifest = { - .archive_version = CBM_ZOVA_OPERATIONS_ARCHIVE_VERSION, - .schema_version = CBM_ZOVA_DATABASE_SCHEMA_VERSION, - }; - int rc = cbm_zova_database_backup(source_path, data, &backup) == CBM_ZOVA_OPERATION_OK ? 0 - : -1; - if (rc == 0) - rc = operations_file_sha256(data, manifest.database_sha256, - &manifest.database_bytes); - if (rc == 0) rc = operations_write_archive_manifest(data, manifest_path, &manifest); - if (rc == 0) rc = operations_remove_closed_database_sidecars(data); - if (rc == 0) rc = operations_verify_archive(partial, NULL, NULL); - if (rc == 0) rc = operations_remove_closed_database_sidecars(data); - if (rc == 0) rc = operations_archive_has_exact_members(partial); - if (rc == 0) rc = operations_sync_directory(partial); - if (rc == 0 && rename(partial, archive_directory) != 0) rc = -1; - if (rc == 0) rc = operations_sync_directory(parent); - if (rc != 0) { - operations_remove_partial_archive(partial); - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "export_database", - "archive_verification_failed"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - operations_report(out, CBM_ZOVA_OPERATION_OK, "export_database", "ok"); - out->archive_version = manifest.archive_version; - out->schema_version = manifest.schema_version; - out->database_bytes = manifest.database_bytes; - out->elapsed_ms = ((double)(finished.tv_sec - started.tv_sec) * 1000.0) + - ((double)(finished.tv_nsec - started.tv_nsec) / 1000000.0); - return CBM_ZOVA_OPERATION_OK; -#endif -} - -cbm_zova_operation_code_t cbm_zova_database_import( - const char *live_path, const char *archive_directory, bool confirm_replace, - cbm_zova_operation_report_t *out) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "import_database", "invalid_argument"); - if (!live_path || !live_path[0] || !archive_directory || !archive_directory[0] || !out) - return CBM_ZOVA_OPERATION_INVALID; - if (!confirm_replace) { - operations_report(out, CBM_ZOVA_OPERATION_CONFIRMATION_REQUIRED, "import_database", - "confirmation_required"); - return CBM_ZOVA_OPERATION_CONFIRMATION_REQUIRED; - } -#if !CBM_WITH_ZOVA - return CBM_ZOVA_OPERATION_INCOMPATIBLE; -#else - struct stat value; - if (cbm_lstat(archive_directory, &value) != 0 || !S_ISDIR(value.st_mode) -#ifndef _WIN32 - || S_ISLNK(value.st_mode) -#endif - ) - return CBM_ZOVA_OPERATION_INVALID; - operations_archive_manifest_t manifest = {0}; - int verified = operations_verify_archive(archive_directory, live_path, &manifest); - if (verified == -2) { - operations_report(out, CBM_ZOVA_OPERATION_INCOMPATIBLE, "import_database", - "archive_version_incompatible"); - return CBM_ZOVA_OPERATION_INCOMPATIBLE; - } - if (verified == -3) return CBM_ZOVA_OPERATION_INVALID; - if (verified != 0) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "import_database", - "archive_verification_failed"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - char data[4096]; - if (snprintf(data, sizeof(data), "%s/data.zova", archive_directory) >= (int)sizeof(data)) - return CBM_ZOVA_OPERATION_INVALID; - cbm_zova_operation_code_t code = cbm_zova_database_restore(live_path, data, true, out); - if (code == CBM_ZOVA_OPERATION_OK) { - snprintf(out->operation, sizeof(out->operation), "%s", "import_database"); - out->archive_version = manifest.archive_version; - } - return code; -#endif -} - -cbm_zova_operation_code_t cbm_zova_workspace_export( - const char *source_path, const char *workspace_id, - const char *archive_directory, cbm_zova_operation_report_t *out) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "export_workspace", "invalid_argument"); - if (!source_path || !source_path[0] || !archive_directory || !archive_directory[0] || !out || - cbm_zova_workspace_id_validate(workspace_id) != 0) - return CBM_ZOVA_OPERATION_INVALID; -#if !CBM_WITH_ZOVA - return CBM_ZOVA_OPERATION_INCOMPATIBLE; -#else - if (!operations_path_is_regular_nosymlink(source_path)) return CBM_ZOVA_OPERATION_INVALID; - cbm_zova_workspace_snapshot_t snapshot = {0}; - if (cbm_zova_repository_export_snapshot(source_path, workspace_id, &snapshot) != 0) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "export_workspace", - "workspace_not_ready"); - return CBM_ZOVA_OPERATION_INVALID; - } - char partial[4096], data[4096], manifest_path[4096], parent[4096]; - struct stat value; - cbm_zova_operation_code_t code = CBM_ZOVA_OPERATION_VERIFY_FAILED; - if (snprintf(partial, sizeof(partial), "%s.partial", archive_directory) >= - (int)sizeof(partial) || - snprintf(data, sizeof(data), "%s/data.zova", partial) >= (int)sizeof(data) || - snprintf(manifest_path, sizeof(manifest_path), "%s/manifest.json", partial) >= - (int)sizeof(manifest_path) || - operations_parent_directory(archive_directory, parent, sizeof(parent)) != 0 || - cbm_lstat(archive_directory, &value) == 0) { - code = CBM_ZOVA_OPERATION_INVALID; - goto done; - } - if (cbm_lstat(partial, &value) == 0) { - operations_report(out, CBM_ZOVA_OPERATION_BUSY, "export_workspace", "partial_exists"); - code = CBM_ZOVA_OPERATION_BUSY; - goto done; - } - if (cbm_mkdir_mode(partial, 0700) != 0) { - code = CBM_ZOVA_OPERATION_INVALID; - goto done; - } - struct timespec started = {0}, finished = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - const cbm_zova_workspace_generation_input_t input = { - .root_path = snapshot.root_path, .project = snapshot.project, - .indexed_at = snapshot.indexed_at, .model_fingerprint = snapshot.model_fingerprint, - .vector_dimensions = snapshot.vector_dimensions, .nodes = snapshot.nodes, - .node_count = snapshot.node_count, .edges = snapshot.edges, - .edge_count = snapshot.edge_count, .node_vectors = snapshot.node_vectors, - .node_vector_count = snapshot.node_vector_count, - .token_vectors = snapshot.token_vectors, - .token_vector_count = snapshot.token_vector_count, .file_hashes = snapshot.file_hashes, - .file_hash_count = snapshot.file_hash_count, .project_summary = snapshot.project_summary, - }; - cbm_zova_workspace_generation_result_t published = {0}; - int rc = 0; - const char *failure_phase = "publish_failed"; - rc = cbm_zova_user_database_publish_workspace_at_generation( - data, &input, snapshot.generation, &published); - if (rc == 0) failure_phase = "published_inventory_mismatch"; - if (rc == 0 && !operations_generation_matches(&published, &snapshot.integrity)) rc = -1; - if (rc == 0) { failure_phase = "sidecar_cleanup_failed"; - rc = operations_remove_closed_database_sidecars(data); } - if (rc == 0) { failure_phase = "writer_lock_cleanup_failed"; - rc = operations_remove_closed_writer_lock(data); } - cbm_zova_workspace_snapshot_t verified = {0}; - if (rc == 0) { failure_phase = "published_snapshot_verification_failed"; - rc = cbm_zova_repository_export_snapshot(data, workspace_id, &verified); } - cbm_zova_workspace_snapshot_free(&verified); - operations_archive_manifest_t manifest = { - .archive_version = CBM_ZOVA_OPERATIONS_ARCHIVE_VERSION, - .schema_version = CBM_ZOVA_DATABASE_SCHEMA_VERSION, - }; - if (rc == 0) { failure_phase = "database_digest_failed"; - rc = operations_file_sha256(data, manifest.database_sha256, &manifest.database_bytes); - } - if (rc == 0) { failure_phase = "manifest_write_failed"; - rc = operations_write_workspace_archive_manifest(manifest_path, &manifest, &snapshot); } - if (rc == 0) { failure_phase = "manifest_sidecar_cleanup_failed"; - rc = operations_remove_closed_database_sidecars(data); } - if (rc == 0) { failure_phase = "archive_verification_failed"; - rc = operations_verify_workspace_archive(partial, NULL, NULL); } - if (rc == 0) { failure_phase = "verification_sidecar_cleanup_failed"; - rc = operations_remove_closed_database_sidecars(data); } - if (rc == 0) { failure_phase = "archive_members_failed"; - rc = operations_archive_has_exact_members(partial); } - if (rc == 0) { failure_phase = "partial_sync_failed"; - rc = operations_sync_directory(partial); } - if (rc == 0) { failure_phase = "archive_rename_failed"; - if (rename(partial, archive_directory) != 0) rc = -1; } - if (rc == 0) { failure_phase = "parent_sync_failed"; - rc = operations_sync_directory(parent); } - if (rc != 0) { - operations_remove_partial_archive(partial); - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "export_workspace", - failure_phase); - code = CBM_ZOVA_OPERATION_VERIFY_FAILED; - goto done; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - operations_report(out, CBM_ZOVA_OPERATION_OK, "export_workspace", "ok"); - snprintf(out->workspace_id, sizeof(out->workspace_id), "%s", workspace_id); - out->archive_version = manifest.archive_version; - out->schema_version = manifest.schema_version; - out->generation = snapshot.generation; - out->database_bytes = manifest.database_bytes; - out->elapsed_ms = ((double)(finished.tv_sec - started.tv_sec) * 1000.0) + - ((double)(finished.tv_nsec - started.tv_nsec) / 1000000.0); - code = CBM_ZOVA_OPERATION_OK; -done: - cbm_zova_workspace_snapshot_free(&snapshot); - return code; -#endif -} - -static cbm_zova_operation_code_t operations_coord_refusal(cbm_zova_operation_report_t *out, - const char *operation, - cbm_zova_coord_status_t status, - const char *busy_reason); - -cbm_zova_operation_code_t cbm_zova_workspace_import( - const char *target_path, const char *archive_directory, bool replace, - cbm_zova_operation_report_t *out) { - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "import_workspace", "invalid_argument"); - if (!target_path || !target_path[0] || !archive_directory || !archive_directory[0] || !out) - return CBM_ZOVA_OPERATION_INVALID; -#if !CBM_WITH_ZOVA - return CBM_ZOVA_OPERATION_INCOMPATIBLE; -#else - int target_state = operations_restore_path_state(target_path); - if (target_state < 0) return CBM_ZOVA_OPERATION_INVALID; - operations_archive_manifest_t manifest = {0}; - int archive_status = operations_verify_workspace_archive(archive_directory, target_path, - &manifest); - if (archive_status == -2) { - operations_report(out, CBM_ZOVA_OPERATION_INCOMPATIBLE, "import_workspace", - "archive_version_incompatible"); - return CBM_ZOVA_OPERATION_INCOMPATIBLE; - } - if (archive_status == -3) return CBM_ZOVA_OPERATION_INVALID; - if (archive_status != 0) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "import_workspace", - "archive_verification_failed"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - char data[4096]; - if (snprintf(data, sizeof(data), "%s/data.zova", archive_directory) >= (int)sizeof(data)) - return CBM_ZOVA_OPERATION_INVALID; - char workspace_id[CBM_ZOVA_WORKSPACE_ID_MAX] = {0}; - if (operations_single_ready_workspace(data, workspace_id) != 0) { - (void)operations_cleanup_verified_archive_reads(archive_directory); - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "import_workspace", - "workspace_inventory_invalid"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - cbm_zova_workspace_snapshot_t snapshot = {0}; - if (cbm_zova_repository_export_snapshot(data, workspace_id, &snapshot) != 0) { - (void)operations_cleanup_verified_archive_reads(archive_directory); - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "import_workspace", - "workspace_snapshot_invalid"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - if (operations_cleanup_verified_archive_reads(archive_directory) != 0) { - cbm_zova_workspace_snapshot_free(&snapshot); - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "import_workspace", - "archive_read_cleanup_failed"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - /* The import publishes this workspace, so it holds what an index run of it - * holds: the barrier shared, then the workspace lease. It does not wait. */ - cbm_zova_coord_hold_t barrier = {0}, lease = {0}; - cbm_zova_coord_status_t coord = - cbm_zova_prepare_barrier_acquire(target_path, false, 0, &barrier); - const char *busy_reason = "database_replacement_in_progress"; - if (coord == CBM_ZOVA_COORD_OK) { - coord = cbm_zova_workspace_lease_acquire(target_path, workspace_id, 0, &lease); - busy_reason = "workspace_busy"; - } - if (coord != CBM_ZOVA_COORD_OK) { - cbm_zova_coord_release(&barrier); - cbm_zova_workspace_snapshot_free(&snapshot); - return operations_coord_refusal(out, "import_workspace", coord, busy_reason); - } - struct timespec started = {0}, finished = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - cbm_zova_operation_code_t code = CBM_ZOVA_OPERATION_VERIFY_FAILED; - int schema_version = 0; - if ((target_state == 0 && cbm_zova_user_database_init(target_path) != 0) || - operations_verify_database(target_path, &schema_version) != 0 || - schema_version != CBM_ZOVA_DATABASE_SCHEMA_VERSION) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "import_workspace", - "target_verification_failed"); - goto done; - } - cbm_zova_workspace_snapshot_t collision = {0}; - int existing_present = - cbm_zova_repository_export_snapshot(target_path, workspace_id, &collision) == 0; - if (existing_present && - (strcmp(collision.workspace_id, snapshot.workspace_id) != 0 || - strcmp(collision.root_path, snapshot.root_path) != 0)) { - cbm_zova_workspace_snapshot_free(&collision); - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "import_workspace", - "identity_mismatch"); - code = CBM_ZOVA_OPERATION_INVALID; - goto done; - } - if (existing_present && !replace) { - cbm_zova_workspace_snapshot_free(&collision); - operations_report(out, CBM_ZOVA_OPERATION_INVALID, "import_workspace", - "workspace_exists"); - code = CBM_ZOVA_OPERATION_INVALID; - goto done; - } - if (existing_present && snapshot.generation < collision.generation) { - cbm_zova_workspace_snapshot_free(&collision); - operations_report(out, CBM_ZOVA_OPERATION_INCOMPATIBLE, "import_workspace", - "archive_generation_older"); - code = CBM_ZOVA_OPERATION_INCOMPATIBLE; - goto done; - } - if (existing_present && snapshot.generation == collision.generation && - !operations_generation_matches(&collision.integrity, &snapshot.integrity)) { - cbm_zova_workspace_snapshot_free(&collision); - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "import_workspace", - "same_generation_digest_mismatch"); - code = CBM_ZOVA_OPERATION_VERIFY_FAILED; - goto done; - } - if (existing_present && snapshot.generation == collision.generation) { - cbm_zova_workspace_snapshot_free(&collision); - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - operations_report(out, CBM_ZOVA_OPERATION_NOOP, "import_workspace", "already_current"); - snprintf(out->workspace_id, sizeof(out->workspace_id), "%s", workspace_id); - out->schema_version = schema_version; - out->archive_version = manifest.archive_version; - out->generation = snapshot.generation; - struct stat target_stat; - if (stat(target_path, &target_stat) == 0) - out->database_bytes = (uint64_t)target_stat.st_size; - out->elapsed_ms = ((double)(finished.tv_sec - started.tv_sec) * 1000.0) + - ((double)(finished.tv_nsec - started.tv_nsec) / 1000000.0); - code = CBM_ZOVA_OPERATION_NOOP; - goto done; - } - cbm_zova_workspace_snapshot_free(&collision); - int64_t workspaces_before = 0, workspaces_after = 0; - if (operations_workspace_count(target_path, &workspaces_before) != 0) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "import_workspace", - "target_inventory_failed"); - goto done; - } - const cbm_zova_workspace_generation_input_t input = { - .root_path = snapshot.root_path, .project = snapshot.project, - .indexed_at = snapshot.indexed_at, .model_fingerprint = snapshot.model_fingerprint, - .vector_dimensions = snapshot.vector_dimensions, .nodes = snapshot.nodes, - .node_count = snapshot.node_count, .edges = snapshot.edges, - .edge_count = snapshot.edge_count, .node_vectors = snapshot.node_vectors, - .node_vector_count = snapshot.node_vector_count, - .token_vectors = snapshot.token_vectors, - .token_vector_count = snapshot.token_vector_count, .file_hashes = snapshot.file_hashes, - .file_hash_count = snapshot.file_hash_count, .project_summary = snapshot.project_summary, - }; - cbm_zova_workspace_generation_result_t published = {0}; - int rc = cbm_zova_user_database_publish_workspace_at_generation( - target_path, &input, snapshot.generation, &published); - if (rc == 0 && !operations_generation_matches(&published, &snapshot.integrity)) rc = -1; - cbm_zova_workspace_snapshot_t imported = {0}; - if (rc == 0) rc = cbm_zova_repository_export_snapshot(target_path, workspace_id, &imported); - if (rc == 0 && !operations_generation_matches(&imported.integrity, &snapshot.integrity)) - rc = -1; - if (rc == 0) rc = operations_workspace_count(target_path, &workspaces_after); - if (rc == 0 && workspaces_after != workspaces_before + (existing_present ? 0 : 1)) rc = -1; - cbm_zova_workspace_snapshot_free(&imported); - if (rc != 0) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "import_workspace", - "published_verification_failed"); - goto done; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - operations_report(out, CBM_ZOVA_OPERATION_OK, "import_workspace", "ok"); - snprintf(out->workspace_id, sizeof(out->workspace_id), "%s", workspace_id); - out->schema_version = schema_version; - out->archive_version = manifest.archive_version; - out->generation = snapshot.generation; - struct stat target_stat; - if (stat(target_path, &target_stat) == 0) - out->database_bytes = (uint64_t)target_stat.st_size; - out->elapsed_ms = ((double)(finished.tv_sec - started.tv_sec) * 1000.0) + - ((double)(finished.tv_nsec - started.tv_nsec) / 1000000.0); - code = CBM_ZOVA_OPERATION_OK; -done: - cbm_zova_coord_release(&lease); - cbm_zova_coord_release(&barrier); - cbm_zova_workspace_snapshot_free(&snapshot); - return code; -#endif -} - -static cbm_zova_operation_code_t cbm_zova_database_repack_body( - const cbm_zova_repack_request_t *request, cbm_zova_repack_report_t *out) { - if (out) memset(out, 0, sizeof(*out)); - if (!request || !request->live_path || !request->live_path[0] || !out) { - if (out) out->code = CBM_ZOVA_OPERATION_INVALID; - return CBM_ZOVA_OPERATION_INVALID; - } -#if !CBM_WITH_ZOVA - out->code = CBM_ZOVA_OPERATION_INCOMPATIBLE; - return out->code; -#else - const char *live = request->live_path; - size_t length = strlen(live); - char temporary[4096], recovery[4096], parent[4096]; - if (length <= 5 || strcmp(live + length - 5, ".zova") != 0 || length - 5 > 4000 || - snprintf(temporary,sizeof(temporary),"%.*s.repack.tmp.zova",(int)(length-5),live)>=(int)sizeof(temporary)|| - snprintf(recovery,sizeof(recovery),"%.*s.repack.recovery.zova",(int)(length-5),live)>=(int)sizeof(recovery)|| - operations_parent_directory(live,parent,sizeof(parent))!=0) { - out->code=CBM_ZOVA_OPERATION_INVALID;return out->code; - } - snprintf(out->recovery_path,sizeof(out->recovery_path),"%s",recovery); - cbm_zova_writer_guard_t guard={0}; - if(cbm_zova_writer_guard_try_acquire(live,&guard)!=0){out->code=CBM_ZOVA_OPERATION_BUSY;return out->code;} - int live_state=operations_restore_path_state(live); - int temp_state=operations_restore_path_state(temporary); - int recovery_state=operations_restore_path_state(recovery); - if(live_state<0||temp_state<0||recovery_state<0){out->code=CBM_ZOVA_OPERATION_VERIFY_FAILED;goto done;} - - if(live_state==1&&temp_state==0&&recovery_state==0){ - if(operations_verify_database(live,NULL)==0){ - out->code=CBM_ZOVA_OPERATION_NOOP; - out->source_cbm_schema=CBM_ZOVA_DATABASE_SCHEMA_VERSION; - out->target_cbm_schema=CBM_ZOVA_DATABASE_SCHEMA_VERSION; - goto done; - } - if(operations_raw_sqlite_checkpoint_truncate(live)!=0){ - out->code=CBM_ZOVA_OPERATION_BUSY;goto done; - } - cbm_log_info("zova.repack","phase","source_checkpointed"); - cbm_zova_v5_snapshot_t *snapshot=NULL; - if(cbm_zova_v5_snapshot_open(live,&snapshot)!=0||!snapshot){ - out->code=CBM_ZOVA_OPERATION_INCOMPATIBLE;goto done; - } - int count=cbm_zova_v5_snapshot_workspace_count(snapshot); - cbm_log_info("zova.repack","phase","source_snapshot","workspaces",count==2?"2":"other"); - out->source_cbm_schema=5; - out->target_cbm_schema=CBM_ZOVA_DATABASE_SCHEMA_VERSION; - out->workspace_count=count; - uint64_t ignored_bytes=0; - if(count<1||operations_file_sha256(live,out->source_sha256,&ignored_bytes)!=0){ - cbm_zova_v5_snapshot_close(snapshot);out->code=CBM_ZOVA_OPERATION_VERIFY_FAILED;goto done; - } - (void)operations_remove_closed_database_sidecars(temporary); - (void)cbm_unlink(temporary); - /* The gate has one process-wide mutex, so publishing the isolated - * sibling temp while holding the live guard would self-deadlock. - * Revalidate the source digest after reacquiring before replacement. */ - cbm_zova_writer_guard_release(&guard); - int rc=0; - for(int i=0;rc==0&&iroot_path,workspace_id,sizeof(workspace_id))!=0|| - cbm_zova_workspace_generation_digest_input(workspace_id,input,&expected)!=0)rc=-1; - if(rc==0){ - expected.generation=generation; - snprintf(expected.workspace_id,sizeof(expected.workspace_id),"%s",workspace_id); - if(cbm_zova_user_database_publish_workspace_at_generation( - temporary,input,generation,&published)!=0|| - !operations_generation_matches(&published,&expected))rc=-1; - } - if(rc==0)cbm_log_info("zova.repack","phase","workspace_published"); - } - cbm_zova_v5_snapshot_close(snapshot); - if(rc==0&&cbm_zova_writer_guard_try_acquire(live,&guard)!=0){ - out->code=CBM_ZOVA_OPERATION_BUSY;goto done; - } - if(rc==0){ - char current_source[CBM_ZOVA_DIGEST_HEX_SIZE]; - if(operations_file_sha256(live,current_source,&ignored_bytes)!=0|| - strcmp(current_source,out->source_sha256)!=0)rc=-1; - } - int schema=0; - zova_database *target_db=NULL; - zova_message error={0}; - int64_t target_workspaces=-1,forbidden=-1; - if(rc==0&&operations_verify_database(temporary,&schema)!=0)rc=-1; - if(rc==0)cbm_log_info("zova.repack","phase","target_verified"); - if(rc==0&&(zova_database_open_with_options(&(zova_database_open_options_request){ - .path=temporary,.flags=0,.busy_timeout_ms=5000,.out_db=&target_db, - .out_error_message=&error})!=ZOVA_OK||!target_db))rc=-1; - zova_message_free(&error); - if(rc==0)rc=operations_checkpoint_truncate(target_db); - if(rc==0)rc=operations_query_i64(target_db,"SELECT count(*) FROM cbm_workspace_registry",&target_workspaces); - if(rc==0)rc=operations_query_i64(target_db, - "SELECT count(*) FROM sqlite_master WHERE name IN ('cbm_zova_trace_nodes_v1'," - "'cbm_zova_edge_metadata_v1','cbm_fts_rowmap_v1','cbm_node_vectors_compat_v1'," - "'cbm_token_vectors_compat_v1') OR name GLOB 'cbm_fts_w1_*'",&forbidden); - if(target_db)(void)zova_database_close(target_db); - if(rc==0&&(schema!=CBM_ZOVA_DATABASE_SCHEMA_VERSION||target_workspaces!=count||forbidden!=0))rc=-1; - if(rc==0&&operations_file_sha256(temporary,out->target_sha256,&ignored_bytes)!=0)rc=-1; -#ifndef _WIN32 - if(rc==0){int fd=open(temporary,O_RDONLY);if(fd<0||fsync(fd)!=0)rc=-1;if(fd>=0)close(fd);} - if(rc==0){int fd=open(live,O_RDONLY);if(fd<0||fsync(fd)!=0)rc=-1;if(fd>=0)close(fd);} -#endif - if(rc==0&&operations_sync_directory(parent)!=0)rc=-1; - if(rc!=0){out->code=CBM_ZOVA_OPERATION_VERIFY_FAILED;goto done;} - temp_state=1; - if(operations_replacement_fault("repack","repack_after_temp_creation",NULL)){ - out->code=CBM_ZOVA_OPERATION_VERIFY_FAILED;goto done; - } - } - - if(live_state==1&&temp_state==0&&recovery_state==1&&operations_verify_database(live,NULL)==0){ - out->code=CBM_ZOVA_OPERATION_NOOP; - out->source_cbm_schema=5; - out->target_cbm_schema=CBM_ZOVA_DATABASE_SCHEMA_VERSION; - uint64_t ignored=0; - (void)operations_file_sha256(recovery,out->source_sha256,&ignored); - (void)operations_file_sha256(live,out->target_sha256,&ignored); - if(!request->keep_recovery&& - (operations_remove_closed_database_sidecars(recovery)!=0|| - cbm_unlink(recovery)!=0||operations_sync_directory(parent)!=0)){ - out->code=CBM_ZOVA_OPERATION_VERIFY_FAILED;goto done; - } - goto done; - } - const operations_replacement_options_t replacement={ - .operation="repack",.after_temp_verification="repack_after_temp_verification", - .after_live_to_recovery="repack_after_live_to_recovery", - .after_temp_to_live="repack_after_temp_to_live", - .before_recovery_cleanup="repack_before_recovery_cleanup", - .remove_sidecars=true,.require_source_verified=false,.keep_recovery=request->keep_recovery, - }; - if(out->source_sha256[0]=='\0'){ - uint64_t ignored=0; - const char *source_path=recovery_state==1?recovery:live; - if(operations_file_sha256(source_path,out->source_sha256,&ignored)!=0){ - out->code=CBM_ZOVA_OPERATION_VERIFY_FAILED;goto done; - } - } - int target_schema=0; - int replaced=operations_replace_verified_database(live,temporary,recovery,parent, - &replacement,&target_schema,NULL); - cbm_log_info("zova.repack","phase","replacement_returned"); - if(replaced!=0){out->code=CBM_ZOVA_OPERATION_VERIFY_FAILED;goto done;} - out->source_cbm_schema=5; - out->target_cbm_schema=target_schema; - int64_t count=0; - if(operations_workspace_count(live,&count)!=0||count>INT_MAX){ - out->code=CBM_ZOVA_OPERATION_VERIFY_FAILED;goto done; - } - out->workspace_count=(int)count; - uint64_t ignored=0; - if(request->keep_recovery) - (void)operations_file_sha256(recovery,out->source_sha256,&ignored); - if(operations_file_sha256(live,out->target_sha256,&ignored)!=0){ - out->code=CBM_ZOVA_OPERATION_VERIFY_FAILED;goto done; - } - out->code=CBM_ZOVA_OPERATION_OK; -done: - cbm_zova_writer_guard_release(&guard); - return out->code; -#endif -} - -/* ── Coordination (ADR-0001) ─────────────────────────────────────── */ - -/* Map a refused lease or barrier acquisition onto the operation report. */ -static cbm_zova_operation_code_t operations_coord_refusal(cbm_zova_operation_report_t *out, - const char *operation, - cbm_zova_coord_status_t status, - const char *busy_reason) { - if (status == CBM_ZOVA_COORD_ERROR) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, operation, - "coordination_unavailable"); - return CBM_ZOVA_OPERATION_VERIFY_FAILED; - } - operations_report(out, CBM_ZOVA_OPERATION_BUSY, operation, - status == CBM_ZOVA_COORD_CANCELLED ? "cancelled" : busy_reason); - return CBM_ZOVA_OPERATION_BUSY; -} - -/* Deleting a workspace mutates it, so it takes the workspace lease. It does not - * wait: callers that should wait (MCP delete_project) retry on BUSY. */ -cbm_zova_operation_code_t cbm_zova_workspace_delete( - const char *path, const char *workspace_id, const char *confirmation, - cbm_zova_operation_report_t *out) { - if (!CBM_WITH_ZOVA || !path || !path[0] || cbm_zova_workspace_id_validate(workspace_id) != 0) - return cbm_zova_workspace_delete_body(path, workspace_id, confirmation, out); - cbm_zova_coord_hold_t lease = {0}; - cbm_zova_coord_status_t status = cbm_zova_workspace_lease_acquire(path, workspace_id, 0, &lease); - if (status != CBM_ZOVA_COORD_OK) - return operations_coord_refusal(out, "delete_workspace", status, "workspace_busy"); - cbm_zova_operation_code_t code = - cbm_zova_workspace_delete_body(path, workspace_id, confirmation, out); - cbm_zova_coord_release(&lease); - return code; -} - -/* Recovery rebuilds the workspace through a pipeline, so it holds what every - * index run holds: the barrier shared, then the workspace lease. */ -/* Quarantine replaces the database under every workspace, so like restore it - * holds the prepare barrier exclusive (index runs elsewhere hold it shared), and - * it re-checks health under that hold before moving the file aside. */ -static cbm_zova_operation_code_t operations_recover_quarantine( - const char *path, const char *workspace_id, cbm_zova_operation_report_t *out) { -#if !CBM_WITH_ZOVA - (void)path; - (void)workspace_id; - return out->code; -#else - cbm_zova_coord_hold_t barrier = {0}; - cbm_zova_coord_status_t status = cbm_zova_prepare_barrier_acquire(path, true, 0, &barrier); - if (status != CBM_ZOVA_COORD_OK) - return operations_coord_refusal(out, "recover_workspace", status, - "indexing_in_progress"); - cbm_zova_health_class_t health = CBM_ZOVA_HEALTH_OK; - cbm_zova_operation_report_t health_report = {0}; - cbm_zova_operation_code_t health_code = - cbm_zova_database_health(path, workspace_id, &health, &health_report); - if (health_code != CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED || - health != CBM_ZOVA_HEALTH_WHOLE_FILE_RECOVERY) { - /* The file changed state while no hold was taken; report what it is now. */ - operations_report(out, - health_code == CBM_ZOVA_OPERATION_OK ? CBM_ZOVA_OPERATION_NOOP - : health_code, - "recover_workspace", - health_code == CBM_ZOVA_OPERATION_OK ? "workspace_healthy" - : health_report.reason); - } else if (operations_quarantine_database(path) != 0) { - operations_report(out, CBM_ZOVA_OPERATION_VERIFY_FAILED, "recover_workspace", - "quarantine_failed"); - } else { - operations_report(out, CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED, - "recover_workspace", "quarantined_no_verified_backup"); - snprintf(out->workspace_id, sizeof(out->workspace_id), "%s", workspace_id); - } - cbm_zova_coord_release(&barrier); - return out->code; -#endif -} - -cbm_zova_operation_code_t cbm_zova_workspace_recover( - const char *path, const char *workspace_id, const char *repo_path, - cbm_zova_operation_report_t *out) { - if (!CBM_WITH_ZOVA || !path || !path[0] || cbm_zova_workspace_id_validate(workspace_id) != 0) - return cbm_zova_workspace_recover_body(path, workspace_id, repo_path, out); - cbm_zova_coord_hold_t barrier = {0}, lease = {0}; - cbm_zova_coord_status_t status = cbm_zova_prepare_barrier_acquire(path, false, 0, &barrier); - if (status != CBM_ZOVA_COORD_OK) - return operations_coord_refusal(out, "recover_workspace", status, - "database_replacement_in_progress"); - status = cbm_zova_workspace_lease_acquire(path, workspace_id, 0, &lease); - if (status != CBM_ZOVA_COORD_OK) { - cbm_zova_coord_release(&barrier); - return operations_coord_refusal(out, "recover_workspace", status, "workspace_busy"); - } - cbm_zova_operation_code_t code = - cbm_zova_workspace_recover_body(path, workspace_id, repo_path, out); - cbm_zova_coord_release(&lease); - cbm_zova_coord_release(&barrier); - if (code != CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED || - strcmp(out->reason, "whole_file_recovery_required") != 0) - return code; - return operations_recover_quarantine(path, workspace_id, out); -} - -/* Restore replaces the database file, so it waits (bounded) for index runs - * that prepared against the current file to finish, and keeps new ones from - * starting until the replacement is done. */ -cbm_zova_operation_code_t cbm_zova_database_restore( - const char *live_path, const char *backup_path, bool confirm_replace, - cbm_zova_operation_report_t *out) { - if (!CBM_WITH_ZOVA || !live_path || !live_path[0] || !backup_path || !backup_path[0] || - !confirm_replace) - return cbm_zova_database_restore_body(live_path, backup_path, confirm_replace, out); - cbm_zova_coord_hold_t barrier = {0}; - cbm_zova_coord_status_t status = cbm_zova_prepare_barrier_acquire( - live_path, true, cbm_zova_coord_default_wait_ms(), &barrier); - if (status != CBM_ZOVA_COORD_OK) - return operations_coord_refusal(out, "restore", status, "indexing_in_progress"); - cbm_zova_operation_code_t code = - cbm_zova_database_restore_body(live_path, backup_path, confirm_replace, out); - cbm_zova_coord_release(&barrier); - return code; -} - -/* Repack replaces the database file; like its writer-gate acquisition it does - * not wait. */ -cbm_zova_operation_code_t cbm_zova_database_repack( - const cbm_zova_repack_request_t *request, cbm_zova_repack_report_t *out) { - if (!CBM_WITH_ZOVA || !request || !request->live_path || !request->live_path[0] || !out) - return cbm_zova_database_repack_body(request, out); - cbm_zova_coord_hold_t barrier = {0}; - cbm_zova_coord_status_t status = - cbm_zova_prepare_barrier_acquire(request->live_path, true, 0, &barrier); - if (status != CBM_ZOVA_COORD_OK) { - memset(out, 0, sizeof(*out)); - out->code = status == CBM_ZOVA_COORD_ERROR ? CBM_ZOVA_OPERATION_VERIFY_FAILED - : CBM_ZOVA_OPERATION_BUSY; - return out->code; - } - cbm_zova_operation_code_t code = cbm_zova_database_repack_body(request, out); - cbm_zova_coord_release(&barrier); - return code; -} diff --git a/src/zova/cbm_zova_operations.zig b/src/zova/cbm_zova_operations.zig new file mode 100644 index 00000000..5bdf3431 --- /dev/null +++ b/src/zova/cbm_zova_operations.zig @@ -0,0 +1,2383 @@ +//! cbm_zova_operations.zig — Maintenance operations on a cbm.zova file: +//! status, compaction, format migration, backup and restore, database and +//! workspace archives, health and recovery, and the schema-5 repack. +//! +//! Implements src/zova/cbm_zova_operations.h. Each operation returns a code +//! and describes the outcome in its report. Without Zova an operation checks +//! its arguments and returns INCOMPATIBLE. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); +const interop = @import("../foundation/interop.zig"); + +const Str = interop.Str; +const span = std.mem.span; +const sliceTo = std.mem.sliceTo; + +const Code = c.cbm_zova_operation_code_t; +const Report = c.cbm_zova_operation_report_t; +const HealthClass = c.cbm_zova_health_class_t; +const RepackRequest = c.cbm_zova_repack_request_t; +const RepackReport = c.cbm_zova_repack_report_t; +const CoordStatus = c.cbm_zova_coord_status_t; +const CoordHold = c.cbm_zova_coord_hold_t; +const WriterGuard = c.cbm_zova_writer_guard_t; +const Snapshot = c.cbm_zova_workspace_snapshot_t; +const GenerationInput = c.cbm_zova_workspace_generation_input_t; +const GenerationResult = c.cbm_zova_workspace_generation_result_t; + +// Zova SDK types. They exist only with Zova; nothing reaches them without it. +const Db = c.zova_database; +const Stmt = c.zova_statement; + +const with_zova = c.CBM_WITH_ZOVA != 0; +const is_windows = builtin.os.tag == .windows; + +const OK: Code = c.CBM_ZOVA_OPERATION_OK; +const NOOP: Code = c.CBM_ZOVA_OPERATION_NOOP; +const INVALID: Code = c.CBM_ZOVA_OPERATION_INVALID; +const BUSY: Code = c.CBM_ZOVA_OPERATION_BUSY; +const INCOMPATIBLE: Code = c.CBM_ZOVA_OPERATION_INCOMPATIBLE; +const VERIFY_FAILED: Code = c.CBM_ZOVA_OPERATION_VERIFY_FAILED; +const DISK_REFUSED: Code = c.CBM_ZOVA_OPERATION_DISK_REFUSED; +const CONFIRMATION_REQUIRED: Code = c.CBM_ZOVA_OPERATION_CONFIRMATION_REQUIRED; +const WORKSPACE_REBUILD_REQUIRED: Code = c.CBM_ZOVA_OPERATION_WORKSPACE_REBUILD_REQUIRED; +const WHOLE_FILE_RECOVERY_REQUIRED: Code = c.CBM_ZOVA_OPERATION_WHOLE_FILE_RECOVERY_REQUIRED; + +const PATH_CAP = 4096; +const ID_MAX: usize = c.CBM_ZOVA_WORKSPACE_ID_MAX; +const HEX_LEN: usize = c.CBM_SHA256_HEX_LEN; +const SCHEMA_VERSION = c.CBM_ZOVA_DATABASE_SCHEMA_VERSION; +const ARCHIVE_VERSION = c.CBM_ZOVA_OPERATIONS_ARCHIVE_VERSION; + +const schema_version_sql = "SELECT schema_version FROM cbm_database_schema_v1 WHERE id=1"; +const workspace_count_sql = "SELECT count(*) FROM cbm_workspace_registry"; + +/// Counts the workspaces whose active generation is missing, not ready, or +/// has no integrity row. +const invalid_generations_sql = + "SELECT count(*) FROM cbm_workspace_registry w LEFT JOIN " ++ + "cbm_database_generation_v1 g ON g.workspace_key=w.workspace_key AND " ++ + "g.generation=w.active_generation AND g.state='ready' LEFT JOIN " ++ + "cbm_generation_integrity_v2 i ON i.workspace_key=w.workspace_key AND " ++ + "i.generation=w.active_generation WHERE w.active_generation<=0 OR " ++ + "g.workspace_key IS NULL OR i.workspace_key IS NULL"; + +// ── Reports ───────────────────────────────────────────────────── + +/// Resets `out` (when given) to `code`, `operation` and `reason`, and returns +/// `code` so a refusal is one statement. +fn operationsReport(out: ?*Report, code: Code, operation: []const u8, reason: []const u8) Code { + if (out) |report| { + report.* = std.mem.zeroes(Report); + report.code = code; + _ = interop.bprint(&report.operation, "{s}", .{operation}); + _ = interop.bprint(&report.reason, "{s}", .{reason}); + } + return code; +} + +fn operationsSetWorkspaceId(out: *Report, workspace_id: Str) void { + _ = interop.bprint(&out.workspace_id, "{s}", .{span(workspace_id)}); +} + +fn operationsCopyStatusMetrics(out: *Report, status: *const Report) void { + out.schema_version = status.schema_version; + out.database_bytes = status.database_bytes; + out.wal_bytes = status.wal_bytes; + out.free_bytes = status.free_bytes; + out.page_size = status.page_size; + out.page_count = status.page_count; + out.freelist_count = status.freelist_count; +} + +fn operationsNow() c.struct_timespec { + var now = std.mem.zeroes(c.struct_timespec); + _ = c.cbm_clock_gettime(c.CLOCK_MONOTONIC, &now); + return now; +} + +fn operationsElapsedMs(started: c.struct_timespec, finished: c.struct_timespec) f64 { + return @as(f64, @floatFromInt(finished.tv_sec - started.tv_sec)) * 1000.0 + + @as(f64, @floatFromInt(finished.tv_nsec - started.tv_nsec)) / 1000000.0; +} + +/// The string when it is neither NULL nor empty. +fn nonEmpty(text: ?Str) ?Str { + const value = text orelse return null; + return if (value[0] != 0) value else null; +} + +/// strcmp() == 0 for two NUL-terminated buffers. +fn bufEql(left: []const u8, right: []const u8) bool { + return std.mem.eql(u8, sliceTo(left, 0), sliceTo(right, 0)); +} + +// ── Zova SDK calls ────────────────────────────────────────────── + +/// zova_database_open_with_options. Null unless the open returned ZOVA_OK and +/// a handle; `out_status` (optional) receives the status either way. +fn operationsOpen(path: Str, flags: u32, busy_timeout_ms: u32, out_status: ?*c.zova_status) ?*Db { + var db: ?*Db = null; + var message = std.mem.zeroes(c.zova_message); + const status = c.zova_database_open_with_options(&c.zova_database_open_options_request{ + .path = path, + .flags = flags, + .busy_timeout_ms = busy_timeout_ms, + .out_db = &db, + .out_error_message = &message, + }); + c.zova_message_free(&message); + if (out_status) |out| out.* = status; + if (status == c.ZOVA_OK) return db; + if (db) |opened| _ = c.zova_database_close(opened); + return null; +} + +/// Read-only with the 5 s busy timeout every reader here uses. +fn operationsOpenReadOnly(path: Str) ?*Db { + return operationsOpen(path, c.ZOVA_OPEN_READ_ONLY, 5000, null); +} + +/// Null unless the prepare returned ZOVA_OK and a statement; `out_status` +/// (optional) receives the status either way. +fn operationsPrepare(db: *Db, sql: Str, out_status: ?*c.zova_status) ?*Stmt { + var statement: ?*Stmt = null; + const status = c.zova_database_prepare(&c.zova_database_prepare_request{ + .db = db, + .sql = sql, + .out_statement = &statement, + }); + if (out_status) |out| out.* = status; + if (status == c.ZOVA_OK) return statement; + if (statement) |prepared| _ = c.zova_statement_finalize(prepared); + return null; +} + +fn operationsStep(statement: *Stmt, out: *c.zova_step_result) bool { + return c.zova_statement_step(&c.zova_statement_step_request{ + .statement = statement, + .out_result = out, + }) == c.ZOVA_OK; +} + +/// Steps once; true when the step succeeded with `expected`. +fn operationsStepIs(statement: *Stmt, expected: c.zova_step_result) bool { + var step: c.zova_step_result = if (expected == c.ZOVA_STEP_ROW) c.ZOVA_STEP_DONE else c.ZOVA_STEP_ROW; + return operationsStep(statement, &step) and step == expected; +} + +fn operationsColumnI64(statement: *Stmt, index: c_int, out: *i64) bool { + return c.zova_statement_column_int64(&c.zova_statement_column_int64_request{ + .statement = statement, + .index = index, + .out_value = out, + }) == c.ZOVA_OK; +} + +/// True when the column was read and is not NULL. The caller frees `out` +/// with zova_text_free either way. +fn operationsColumnText(statement: *Stmt, index: c_int, out: *c.zova_text) bool { + return c.zova_statement_column_text(&c.zova_statement_column_text_request{ + .statement = statement, + .index = index, + .out_text = out, + }) == c.ZOVA_OK and out.data != null; +} + +fn operationsBindText(statement: *Stmt, index: c_int, text: Str) bool { + return c.zova_statement_bind_text(&c.zova_statement_bind_text_request{ + .statement = statement, + .index = index, + .data = text, + .len = std.mem.len(text), + }) == c.ZOVA_OK; +} + +fn operationsBindI64(statement: *Stmt, index: c_int, value: i64) bool { + return c.zova_statement_bind_int64(&c.zova_statement_bind_int64_request{ + .statement = statement, + .index = index, + .value = value, + }) == c.ZOVA_OK; +} + +/// The text as printf's "%.*s" prints it: its length, or up to a NUL. +fn operationsTextSlice(text: c.zova_text) []const u8 { + return sliceTo(text.data[0..text.len], 0); +} + +/// Copies a non-empty text that fits into `out` as a C string. +fn operationsTextInto(text: c.zova_text, out: []u8) bool { + if (text.len == 0 or text.len >= out.len) return false; + @memcpy(out[0..text.len], text.data[0..text.len]); + out[text.len] = 0; + return true; +} + +fn operationsQueryI64(db: *Db, sql: Str, out: *i64) bool { + const statement = operationsPrepare(db, sql, null) orelse return false; + defer _ = c.zova_statement_finalize(statement); + return operationsStepIs(statement, c.ZOVA_STEP_ROW) and operationsColumnI64(statement, 0, out); +} + +/// Statuses that say the database could not be read right now (locked by +/// another connection, out of memory or file descriptors), not that it is +/// damaged. +fn operationsStatusIsTransient(status: c.zova_status) bool { + return status == c.ZOVA_BUSY or status == c.ZOVA_LOCKED or status == c.ZOVA_OUT_OF_MEMORY or + status == c.ZOVA_CANT_OPEN; +} + +/// PRAGMA quick_check. `out_status` (optional) receives the status of a +/// failed prepare or step; it stays ZOVA_OK when the check ran and found +/// damage. +fn operationsQuickCheckStatus(db: *Db, out_status: ?*c.zova_status) bool { + if (out_status) |out| out.* = c.ZOVA_OK; + var status: c.zova_status = c.ZOVA_OK; + const statement = operationsPrepare(db, "PRAGMA quick_check", &status) orelse { + if (out_status) |out| out.* = status; + return false; + }; + defer _ = c.zova_statement_finalize(statement); + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + status = c.zova_statement_step(&c.zova_statement_step_request{ + .statement = statement, + .out_result = &step, + }); + if (status != c.ZOVA_OK) { + if (out_status) |out| out.* = status; + return false; + } + var text = std.mem.zeroes(c.zova_text); + defer c.zova_text_free(&text); + return step == c.ZOVA_STEP_ROW and operationsColumnText(statement, 0, &text) and + text.len == 2 and std.mem.eql(u8, text.data[0..2], "ok"); +} + +fn operationsQuickCheck(db: *Db) bool { + return operationsQuickCheckStatus(db, null); +} + +fn operationsForeignKeysOk(db: *Db) bool { + const statement = operationsPrepare(db, "PRAGMA foreign_key_check", null) orelse return false; + defer _ = c.zova_statement_finalize(statement); + return operationsStepIs(statement, c.ZOVA_STEP_DONE); +} + +/// Opens `path` read-only and checks that it is a sound database of the +/// current schema whose every workspace has a ready, verified generation. +fn operationsVerifyDatabase(path: Str, out_schema_version: ?*c_int) bool { + const db = operationsOpenReadOnly(path) orelse return false; + defer _ = c.zova_database_close(db); + var schema_version: i64 = 0; + var invalid_generations: i64 = 0; + if (!(operationsQueryI64(db, schema_version_sql, &schema_version) and + schema_version == SCHEMA_VERSION and operationsQuickCheck(db) and + operationsForeignKeysOk(db) and + operationsQueryI64(db, invalid_generations_sql, &invalid_generations) and + invalid_generations == 0)) + return false; + if (out_schema_version) |out| out.* = @intCast(schema_version); + return true; +} + +// ── Files and paths ───────────────────────────────────────────── + +/// snprintf into a path buffer; null when the path does not fit. +fn operationsPath(buf: *[PATH_CAP]u8, comptime fmt: []const u8, args: anytype) ?Str { + if (interop.bprint(buf, fmt, args) >= buf.len) return null; + return @ptrCast(buf); +} + +/// The directory holding `path`, written to `buf`: "." when `path` has no +/// separator. +fn operationsParentDirectory(path: []const u8, buf: *[PATH_CAP]u8) ?Str { + if (path.len >= buf.len) return null; + var slash = std.mem.lastIndexOfScalar(u8, path, '/'); + if (is_windows) { + const backslash = std.mem.lastIndexOfScalar(u8, path, '\\'); + if (slash == null or (backslash != null and backslash.? > slash.?)) slash = backslash; + } + const parent: []const u8 = if (slash) |at| path[0..@max(at, 1)] else "."; + @memcpy(buf[0..parent.len], parent); + buf[parent.len] = 0; + return @ptrCast(buf); +} + +/// `path` without its ".zova" suffix. Null when it has no such suffix or is +/// too long for the sibling paths derived from it. +fn operationsZovaStem(path: []const u8) ?[]const u8 { + if (path.len <= 5 or !std.mem.endsWith(u8, path, ".zova") or path.len - 5 > 4000) return null; + return path[0 .. path.len - 5]; +} + +fn operationsSyncDirectory(path: Str) bool { + if (is_windows) return true; + const fd = c.open(path, c.O_RDONLY | c.O_DIRECTORY); + if (fd < 0) return false; + defer _ = c.close(fd); + return c.fsync(fd) == 0; +} + +/// fsync of a file's contents; nothing to do on Windows. +fn operationsSyncFile(path: Str) bool { + if (is_windows) return true; + const fd = c.open(path, c.O_RDONLY); + if (fd < 0) return false; + defer _ = c.close(fd); + return c.fsync(fd) == 0; +} + +fn operationsSyncStream(file: *c.FILE) bool { + if (is_windows) return true; + return c.fsync(c.fileno(file)) == 0; +} + +/// mkdir with mode 0700. The Windows form of cbm_mkdir_mode drops the mode +/// and does not translate; cbm_mkdir is the same _mkdir call there. +fn operationsMakePrivateDirectory(path: Str) bool { + return (if (is_windows) c.cbm_mkdir(path) else c.cbm_mkdir_mode(path, 0o700)) == 0; +} + +fn operationsSameFile(left: Str, right: Str) bool { + var left_stat: c.struct_stat = undefined; + var right_stat: c.struct_stat = undefined; + if (c.stat(left, &left_stat) != 0 or c.stat(right, &right_stat) != 0) return false; + if (is_windows) return std.mem.eql(u8, span(left), span(right)); + return left_stat.st_dev == right_stat.st_dev and left_stat.st_ino == right_stat.st_ino; +} + +/// cbm_lstat: the path itself, never the target of a symlink (plain stat on +/// Windows). Null when the path does not exist. +fn operationsLstat(path: Str) ?c.struct_stat { + var value: c.struct_stat = undefined; + return if (c.cbm_lstat(path, &value) == 0) value else null; +} + +fn operationsIsRegular(value: *const c.struct_stat) bool { + return (value.st_mode & c.S_IFMT) == c.S_IFREG; +} + +fn operationsIsDirectory(value: *const c.struct_stat) bool { + return (value.st_mode & c.S_IFMT) == c.S_IFDIR; +} + +/// st_size of `path` through stat(); null when stat fails. +fn operationsFileSize(path: Str) ?i64 { + var value: c.struct_stat = undefined; + return if (c.stat(path, &value) == 0) value.st_size else null; +} + +/// A file size as the unsigned byte count the reports carry. +fn operationsBytes(size: i64) u64 { + return @bitCast(size); +} + +/// A symlink reports S_IFLNK through lstat, so "regular" already excludes it. +fn operationsPathIsRegularNoSymlink(path: Str) bool { + const value = operationsLstat(path) orelse return false; + return operationsIsRegular(&value); +} + +const PathState = enum { absent, regular, other }; + +fn operationsRestorePathState(path: Str) PathState { + const value = operationsLstat(path) orelse return .absent; + return if (operationsIsRegular(&value)) .regular else .other; +} + +/// Unlinks `path` when it is a regular file. False when it is something +/// else or the unlink fails. +fn operationsRemoveIfRegular(path: Str) bool { + return switch (operationsRestorePathState(path)) { + .absent => true, + .regular => c.cbm_unlink(path) == 0, + .other => false, + }; +} + +fn operationsRemoveClosedDatabaseSidecars(database_path: Str) bool { + var buf: [PATH_CAP]u8 = undefined; + for ([_][]const u8{ "-wal", "-shm", "-journal" }) |suffix| { + const path = operationsPath(&buf, "{s}{s}", .{ span(database_path), suffix }) orelse return false; + if (!operationsRemoveIfRegular(path)) return false; + } + return true; +} + +fn operationsRemoveClosedWriterLock(database_path: Str) bool { + var buf: [PATH_CAP]u8 = undefined; + const path = operationsPath(&buf, "{s}.writer.lock", .{span(database_path)}) orelse return false; + return operationsRemoveIfRegular(path); +} + +/// Bytes available to this user on the filesystem holding `path`. +fn operationsFreeBytes(path: Str) error{ Unavailable, Overflow }!u64 { + if (is_windows) { + var parent_buf: [PATH_CAP]u8 = undefined; + const parent = operationsParentDirectory(span(path), &parent_buf) orelse return error.Unavailable; + var available = std.mem.zeroes(c.ULARGE_INTEGER); + if (c.GetDiskFreeSpaceExA(parent, &available, null, null) == 0) return error.Unavailable; + return available.QuadPart; + } + var filesystem = std.mem.zeroes(c.struct_statvfs); + if (c.statvfs(path, &filesystem) != 0) return error.Unavailable; + const fragment_size: u64 = if (filesystem.f_frsize != 0) filesystem.f_frsize else filesystem.f_bsize; + if (fragment_size == 0) return error.Overflow; + return std.math.mul(u64, filesystem.f_bavail, fragment_size) catch error.Overflow; +} + +// ── Test faults ───────────────────────────────────────────────── + +/// Test hook: CBM_ZOVA_TEST_CRASH_PHASE exits at the named phase and +/// CBM_ZOVA_TEST_FAIL_PHASE makes it fail. +fn operationsTestFault(phase: []const u8) bool { + const crash: [*c]const u8 = c.getenv("CBM_ZOVA_TEST_CRASH_PHASE"); + if (crash != null and std.mem.eql(u8, span(crash), phase)) { + if (is_windows) c.abort() else c._exit(86); + } + const fault: [*c]const u8 = c.getenv("CBM_ZOVA_TEST_FAIL_PHASE"); + return fault != null and std.mem.eql(u8, span(fault), phase); +} + +fn operationsReplacementFault(operation: []const u8, phase: []const u8, out: ?*Report) bool { + if (!operationsTestFault(phase)) return false; + _ = operationsReport(out, VERIFY_FAILED, operation, phase); + return true; +} + +// ── Archives ──────────────────────────────────────────────────── + +const ArchiveManifest = struct { + archive_version: c_int = 0, + schema_version: c_int = 0, + database_bytes: u64 = 0, + database_sha256: [HEX_LEN + 1]u8 = @splat(0), +}; + +fn operationsFileSha256(path: Str, out: *[HEX_LEN + 1]u8, out_bytes: ?*u64) bool { + const opened: ?*c.FILE = c.fopen(path, "rb"); + const file = opened orelse return false; + var context: c.cbm_sha256_ctx = undefined; + c.cbm_sha256_init(&context); + var buffer: [65536]u8 = undefined; + var bytes: u64 = 0; + var failed = false; + while (c.feof(file) == 0) { + const count = c.fread(&buffer, 1, buffer.len, file); + if (count != 0) { + c.cbm_sha256_update(&context, &buffer, count); + bytes += count; + } + if (c.ferror(file) != 0) { + failed = true; + break; + } + } + _ = c.fclose(file); + if (failed) return false; + var digest: [c.CBM_SHA256_DIGEST_LEN]u8 = undefined; + c.cbm_sha256_final(&context, &digest); + const hex = "0123456789abcdef"; + for (digest, 0..) |byte, i| { + out[i * 2] = hex[byte >> 4]; + out[i * 2 + 1] = hex[byte & 15]; + } + out[HEX_LEN] = 0; + if (out_bytes) |total| total.* = bytes; + return true; +} + +fn operationsWrite(file: *c.FILE, bytes: []const u8) bool { + return c.fwrite(bytes.ptr, 1, bytes.len, file) == bytes.len; +} + +/// One workspace's entry of a database archive manifest, from the current +/// row of operationsWorkspaceManifestQuery. +fn operationsWorkspaceManifestEntry(statement: *Stmt, buf: []u8) ?[]const u8 { + var values: [6]c.zova_text = @splat(std.mem.zeroes(c.zova_text)); + defer for (&values) |*value| c.zova_text_free(value); + var generation: i64 = 0; + if (!operationsColumnI64(statement, 1, &generation)) return null; + const columns = [_]c_int{ 0, 2, 3, 4, 5, 6 }; + for (columns, &values) |column, *value| { + if (!operationsColumnText(statement, column, value)) return null; + } + const written = interop.bprint( + buf, + "{{\"workspace_id\":\"{s}\",\"generation\":{d}," ++ + "\"metadata_sha256\":\"{s}\",\"fts_sha256\":\"{s}\"," ++ + "\"topology_sha256\":\"{s}\",\"node_vector_sha256\":\"{s}\"," ++ + "\"token_vector_sha256\":\"{s}\"}}", + .{ + operationsTextSlice(values[0]), generation, + operationsTextSlice(values[1]), operationsTextSlice(values[2]), + operationsTextSlice(values[3]), operationsTextSlice(values[4]), + operationsTextSlice(values[5]), + }, + ); + return if (written < buf.len) buf[0..written] else null; +} + +fn operationsWorkspaceManifestQuery(db: *Db) ?*Stmt { + return operationsPrepare( + db, + "SELECT r.workspace_id,r.active_generation,i.metadata_sha256," ++ + "i.fts_sha256,i.topology_sha256,i.node_vector_sha256," ++ + "i.token_vector_sha256 FROM cbm_workspace_registry r JOIN " ++ + "cbm_generation_integrity_v2 i ON i.workspace_key=r.workspace_key AND " ++ + "i.generation=r.active_generation ORDER BY r.workspace_id", + null, + ); +} + +/// A malloc'd copy of `value` escaped for a JSON string. Null for a NULL +/// `value` and on OOM. +fn operationsJsonEscapeAlloc(value: [*c]const u8) ?[*:0]u8 { + if (value == null) return null; + const source = span(value); + if (source.len > (std.math.maxInt(usize) - 1) / 6) return null; + const escaped = @as(?[*]u8, interop.mallocN(u8, source.len * 6 + 1)) orelse return null; + var used: usize = 0; + const hex = "0123456789abcdef"; + for (source) |byte| { + const short_escape: ?*const [2]u8 = switch (byte) { + '"' => "\\\"", + '\\' => "\\\\", + 0x08 => "\\b", + 0x0c => "\\f", + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + else => null, + }; + if (short_escape) |escape| { + escaped[used..][0..2].* = escape.*; + used += 2; + } else if (byte < 0x20) { + escaped[used..][0..4].* = "\\u00".*; + escaped[used + 4] = hex[byte >> 4]; + escaped[used + 5] = hex[byte & 15]; + used += 6; + } else { + escaped[used] = byte; + used += 1; + } + } + escaped[used] = 0; + return @ptrCast(escaped); +} + +/// The whole manifest of a workspace archive, malloc'd. +fn operationsWorkspaceArchiveManifestText(manifest: *const ArchiveManifest, snapshot: *const Snapshot) ?[*:0]u8 { + const workspace_id = operationsJsonEscapeAlloc(&snapshot.workspace_id); + defer c.free(workspace_id); + const root = operationsJsonEscapeAlloc(snapshot.root_path); + defer c.free(root); + const project = operationsJsonEscapeAlloc(snapshot.project); + defer c.free(project); + const model = operationsJsonEscapeAlloc(snapshot.model_fingerprint); + defer c.free(model); + if (workspace_id == null or root == null or project == null or model == null) return null; + const integrity = &snapshot.integrity; + const format = + "{{\"archive_version\":{d},\"schema_version\":{d},\"archive_kind\":\"workspace\"," ++ + "\"database\":{{\"path\":\"data.zova\",\"bytes\":{d},\"sha256\":\"{s}\"}}," ++ + "\"workspace\":{{\"workspace_id\":\"{s}\",\"canonical_root\":\"{s}\"," ++ + "\"project\":\"{s}\",\"model_fingerprint\":\"{s}\",\"vector_dimensions\":{d}," ++ + "\"generation\":{d},\"graph_nodes\":{d},\"graph_edges\":{d}," ++ + "\"metadata_nodes\":{d},\"metadata_edges\":{d}," ++ + "\"metadata_topology_edges\":{d},\"fts_rows\":{d}," ++ + "\"node_vector_rows\":{d},\"token_vector_rows\":{d}," ++ + "\"node_vectors\":{d},\"token_vectors\":{d}," ++ + "\"metadata_sha256\":\"{s}\",\"fts_sha256\":\"{s}\"," ++ + "\"topology_sha256\":\"{s}\",\"node_vector_sha256\":\"{s}\"," ++ + "\"token_vector_sha256\":\"{s}\"}}}}\n"; + const args = .{ + manifest.archive_version, manifest.schema_version, + manifest.database_bytes, sliceTo(&manifest.database_sha256, 0), + span(workspace_id.?), span(root.?), + span(project.?), span(model.?), + snapshot.vector_dimensions, snapshot.generation, + integrity.graph_nodes, integrity.graph_edges, + integrity.metadata_nodes, integrity.metadata_edges, + integrity.metadata_topology_edges, integrity.fts_rows, + integrity.node_vector_rows, integrity.token_vector_rows, + integrity.node_vectors, integrity.token_vectors, + sliceTo(&integrity.metadata_sha256, 0), sliceTo(&integrity.fts_sha256, 0), + sliceTo(&integrity.topology_sha256, 0), sliceTo(&integrity.node_vector_sha256, 0), + sliceTo(&integrity.token_vector_sha256, 0), + }; + const needed = std.fmt.count(format, args); + const text = @as(?[*]u8, interop.mallocN(u8, needed + 1)) orelse return null; + _ = interop.bprint(text[0 .. needed + 1], format, args); + return @ptrCast(text); +} + +fn operationsWriteWorkspaceArchiveManifest(manifest_path: Str, manifest: *const ArchiveManifest, snapshot: *const Snapshot) bool { + const text = operationsWorkspaceArchiveManifestText(manifest, snapshot) orelse return false; + defer c.free(text); + const opened: ?*c.FILE = c.fopen(manifest_path, "wb"); + const file = opened orelse return false; + defer _ = c.fclose(file); + return operationsWrite(file, span(text)) and c.fflush(file) == 0 and operationsSyncStream(file); +} + +fn operationsWriteArchiveManifest(data_path: Str, manifest_path: Str, manifest: *const ArchiveManifest) bool { + const db = operationsOpenReadOnly(data_path) orelse return false; + defer _ = c.zova_database_close(db); + const opened: ?*c.FILE = c.fopen(manifest_path, "wb"); + const file = opened orelse return false; + defer _ = c.fclose(file); + const statement = operationsWorkspaceManifestQuery(db) orelse return false; + defer _ = c.zova_statement_finalize(statement); + var header_buf: [512]u8 = undefined; + const header_len = interop.bprint( + &header_buf, + "{{\"archive_version\":{d},\"schema_version\":{d}," ++ + "\"database\":{{\"path\":\"data.zova\",\"bytes\":{d}," ++ + "\"sha256\":\"{s}\"}},\"workspaces\":[", + .{ + manifest.archive_version, manifest.schema_version, + manifest.database_bytes, sliceTo(&manifest.database_sha256, 0), + }, + ); + if (header_len >= header_buf.len or !operationsWrite(file, header_buf[0..header_len])) return false; + var first = true; + while (true) { + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!operationsStep(statement, &step)) return false; + if (step == c.ZOVA_STEP_DONE) break; + var entry_buf: [1024]u8 = undefined; + const entry = operationsWorkspaceManifestEntry(statement, &entry_buf) orelse return false; + if (!first and !operationsWrite(file, ",")) return false; + if (!operationsWrite(file, entry)) return false; + first = false; + } + return operationsWrite(file, "]}\n") and c.fflush(file) == 0 and operationsSyncStream(file); +} + +/// The whole file as a malloc'd C string; files over 16 MiB are refused. +fn operationsReadFile(path: Str) ?[*:0]u8 { + const size = operationsFileSize(path) orelse return null; + if (size < 0 or size > 16 * 1024 * 1024) return null; + const length: usize = @intCast(size); + const opened: ?*c.FILE = c.fopen(path, "rb"); + const file = opened orelse return null; + defer _ = c.fclose(file); + const text = @as(?[*]u8, interop.mallocN(u8, length + 1)) orelse return null; + if (c.fread(text, 1, length, file) != length or c.ferror(file) != 0) { + c.free(text); + return null; + } + text[length] = 0; + return @ptrCast(text); +} + +/// An archive directory holds exactly data.zova and manifest.json, both +/// regular files. +fn operationsArchiveHasExactMembers(archive: Str) bool { + const directory = c.cbm_opendir(archive) orelse return false; + defer c.cbm_closedir(directory); + var data_seen: usize = 0; + var manifest_seen: usize = 0; + while (@as(?*c.cbm_dirent_t, c.cbm_readdir(directory))) |entry| { + const name = sliceTo(&entry.name, 0); + const is_data = std.mem.eql(u8, name, "data.zova"); + const is_manifest = std.mem.eql(u8, name, "manifest.json"); + if (!is_data and !is_manifest) return false; + var path_buf: [PATH_CAP]u8 = undefined; + const path = operationsPath(&path_buf, "{s}/{s}", .{ span(archive), name }) orelse return false; + if (!operationsPathIsRegularNoSymlink(path)) return false; + if (is_data) data_seen += 1; + if (is_manifest) manifest_seen += 1; + } + return data_seen == 1 and manifest_seen == 1; +} + +fn operationsCleanupVerifiedArchiveReads(archive: Str) bool { + var data_buf: [PATH_CAP]u8 = undefined; + const data = operationsPath(&data_buf, "{s}/data.zova", .{span(archive)}) orelse return false; + return operationsRemoveClosedDatabaseSidecars(data) and operationsArchiveHasExactMembers(archive); +} + +/// The id of the only workspace in `data_path`, which must be ready. +fn operationsSingleReadyWorkspace(data_path: Str, out: *[ID_MAX]u8) bool { + const db = operationsOpenReadOnly(data_path) orelse return false; + defer _ = c.zova_database_close(db); + const statement = operationsPrepare( + db, + "SELECT r.workspace_id FROM cbm_workspace_registry r " ++ + "JOIN cbm_database_generation_v1 g ON g.workspace_key=r.workspace_key " ++ + "AND g.generation=r.active_generation AND g.state='ready' " ++ + "JOIN cbm_workspace_index_state_v1 s ON s.workspace_key=r.workspace_key " ++ + "AND s.generation=r.active_generation ORDER BY r.workspace_id", + null, + ) orelse return false; + defer _ = c.zova_statement_finalize(statement); + var workspace = std.mem.zeroes(c.zova_text); + defer c.zova_text_free(&workspace); + if (!operationsStepIs(statement, c.ZOVA_STEP_ROW) or + !operationsColumnText(statement, 0, &workspace) or workspace.len >= ID_MAX) + return false; + @memcpy(out[0..workspace.len], workspace.data[0..workspace.len]); + out[workspace.len] = 0; + return operationsStepIs(statement, c.ZOVA_STEP_DONE) and c.cbm_zova_workspace_id_validate(out) == 0; +} + +fn operationsWorkspaceCount(path: Str, out: *i64) bool { + const db = operationsOpenReadOnly(path) orelse return false; + defer _ = c.zova_database_close(db); + return operationsQueryI64(db, workspace_count_sql, out); +} + +fn operationsGenerationMatches(actual: *const GenerationResult, expected: *const GenerationResult) bool { + return actual.generation == expected.generation and + bufEql(&actual.workspace_id, &expected.workspace_id) and + actual.graph_nodes == expected.graph_nodes and + actual.graph_edges == expected.graph_edges and + actual.metadata_nodes == expected.metadata_nodes and + actual.metadata_edges == expected.metadata_edges and + actual.metadata_topology_edges == expected.metadata_topology_edges and + actual.fts_rows == expected.fts_rows and + actual.node_vector_rows == expected.node_vector_rows and + actual.token_vector_rows == expected.token_vector_rows and + actual.node_vectors == expected.node_vectors and + actual.token_vectors == expected.token_vectors and + bufEql(&actual.metadata_sha256, &expected.metadata_sha256) and + bufEql(&actual.fts_sha256, &expected.fts_sha256) and + bufEql(&actual.topology_sha256, &expected.topology_sha256) and + bufEql(&actual.node_vector_sha256, &expected.node_vector_sha256) and + bufEql(&actual.token_vector_sha256, &expected.token_vector_sha256); +} + +/// The publish input that reproduces `snapshot`. It borrows the snapshot's +/// arrays. +fn operationsSnapshotInput(snapshot: *const Snapshot) GenerationInput { + return .{ + .root_path = snapshot.root_path, + .project = snapshot.project, + .indexed_at = snapshot.indexed_at, + .model_fingerprint = snapshot.model_fingerprint, + .vector_dimensions = snapshot.vector_dimensions, + .nodes = snapshot.nodes, + .node_count = snapshot.node_count, + .edges = snapshot.edges, + .edge_count = snapshot.edge_count, + .node_vectors = snapshot.node_vectors, + .node_vector_count = snapshot.node_vector_count, + .token_vectors = snapshot.token_vectors, + .token_vector_count = snapshot.token_vector_count, + .file_hashes = snapshot.file_hashes, + .file_hash_count = snapshot.file_hash_count, + .project_summary = snapshot.project_summary, + }; +} + +const ArchiveKind = enum { database, workspace }; +const ArchiveCheck = enum { valid, malformed, incompatible, path_alias }; + +/// What both archive kinds are checked for first. +const ArchiveContents = struct { + /// manifest.json, malloc'd; the caller frees it. + text: [*:0]u8, + /// Bytes of `text` the manifest header takes. + consumed: usize, + manifest: ArchiveManifest, +}; + +/// The checks both archive kinds share: the directory layout, the manifest +/// header and its versions, and data.zova against the recorded digest. On +/// `.valid`, `data_buf` holds the path of data.zova and `out` the manifest. +fn operationsReadArchive(kind: ArchiveKind, archive: Str, live_path: ?Str, data_buf: *[PATH_CAP]u8, out: *ArchiveContents) ArchiveCheck { + const value = operationsLstat(archive) orelse return .malformed; + if (!operationsIsDirectory(&value)) return .malformed; + if (!operationsArchiveHasExactMembers(archive)) return .malformed; + var manifest_buf: [PATH_CAP]u8 = undefined; + const data = operationsPath(data_buf, "{s}/data.zova", .{span(archive)}) orelse return .malformed; + const manifest_path = operationsPath(&manifest_buf, "{s}/manifest.json", .{span(archive)}) orelse return .malformed; + if (!operationsPathIsRegularNoSymlink(data) or !operationsPathIsRegularNoSymlink(manifest_path)) + return .malformed; + if (live_path) |live| { + if (operationsRestorePathState(live) == .regular and operationsSameFile(data, live)) return .path_alias; + } + const text = operationsReadFile(manifest_path) orelse return .malformed; + var keep_text = false; + defer if (!keep_text) c.free(text); + var parsed = ArchiveManifest{}; + var bytes: c_ulonglong = 0; + var consumed: c_int = 0; + const fields = c.sscanf( + text, + switch (kind) { + .database => "{\"archive_version\":%d,\"schema_version\":%d," ++ + "\"database\":{\"path\":\"data.zova\",\"bytes\":%llu," ++ + "\"sha256\":\"%64[0-9a-f]\"},\"workspaces\":[%n", + .workspace => "{\"archive_version\":%d,\"schema_version\":%d," ++ + "\"archive_kind\":\"workspace\"," ++ + "\"database\":{\"path\":\"data.zova\",\"bytes\":%llu," ++ + "\"sha256\":\"%64[0-9a-f]\"},\"workspace\":%n", + }, + &parsed.archive_version, + &parsed.schema_version, + &bytes, + @as([*c]u8, &parsed.database_sha256), + &consumed, + ); + parsed.database_bytes = bytes; + if (fields != 4 or (kind == .workspace and consumed <= 0) or + sliceTo(&parsed.database_sha256, 0).len != HEX_LEN) + return .malformed; + if (parsed.archive_version != ARCHIVE_VERSION or parsed.schema_version != SCHEMA_VERSION) + return .incompatible; + var digest: [HEX_LEN + 1]u8 = undefined; + var actual_bytes: u64 = 0; + if (!operationsFileSha256(data, &digest, &actual_bytes) or + actual_bytes != parsed.database_bytes or !bufEql(&digest, &parsed.database_sha256) or + !operationsVerifyDatabase(data, null)) + return .malformed; + keep_text = true; + out.* = .{ .text = text, .consumed = @intCast(consumed), .manifest = parsed }; + return .valid; +} + +/// A database archive: the manifest lists exactly the workspaces of +/// data.zova, with their digests. +fn operationsVerifyArchive(archive: Str, live_path: ?Str, out: ?*ArchiveManifest) ArchiveCheck { + var data_buf: [PATH_CAP]u8 = undefined; + var contents: ArchiveContents = undefined; + const check = operationsReadArchive(.database, archive, live_path, &data_buf, &contents); + if (check != .valid) return check; + defer c.free(contents.text); + const db = operationsOpenReadOnly(@ptrCast(&data_buf)) orelse return .malformed; + defer _ = c.zova_database_close(db); + const statement = operationsWorkspaceManifestQuery(db) orelse return .malformed; + defer _ = c.zova_statement_finalize(statement); + var cursor: []const u8 = span(contents.text)[contents.consumed..]; + var first = true; + while (true) { + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!operationsStep(statement, &step)) return .malformed; + if (step == c.ZOVA_STEP_DONE) break; + var entry_buf: [1024]u8 = undefined; + const entry = operationsWorkspaceManifestEntry(statement, &entry_buf) orelse return .malformed; + if (!first) { + if (!std.mem.startsWith(u8, cursor, ",")) return .malformed; + cursor = cursor[1..]; + } + if (!std.mem.startsWith(u8, cursor, entry)) return .malformed; + cursor = cursor[entry.len..]; + first = false; + } + if (!std.mem.eql(u8, cursor, "]}\n")) return .malformed; + if (out) |manifest| manifest.* = contents.manifest; + return .valid; +} + +/// A workspace archive: data.zova holds one ready workspace and the manifest +/// is exactly the one that workspace produces. +fn operationsVerifyWorkspaceArchive(archive: Str, live_path: ?Str, out: ?*ArchiveManifest) ArchiveCheck { + var data_buf: [PATH_CAP]u8 = undefined; + var contents: ArchiveContents = undefined; + const check = operationsReadArchive(.workspace, archive, live_path, &data_buf, &contents); + if (check != .valid) return check; + defer c.free(contents.text); + const data: Str = @ptrCast(&data_buf); + var workspace_id: [ID_MAX]u8 = @splat(0); + var snapshot = std.mem.zeroes(Snapshot); + defer c.cbm_zova_workspace_snapshot_free(&snapshot); + if (!operationsSingleReadyWorkspace(data, &workspace_id) or + c.cbm_zova_repository_export_snapshot(data, &workspace_id, &snapshot) != 0) + return .malformed; + const expected = operationsWorkspaceArchiveManifestText(&contents.manifest, &snapshot) orelse return .malformed; + defer c.free(expected); + if (!std.mem.eql(u8, span(contents.text), span(expected))) return .malformed; + if (out) |manifest| manifest.* = contents.manifest; + return .valid; +} + +fn operationsRemovePartialArchive(partial: Str) void { + var buf: [PATH_CAP]u8 = undefined; + if (operationsPath(&buf, "{s}/manifest.json", .{span(partial)})) |path| _ = c.cbm_unlink(path); + if (operationsPath(&buf, "{s}/data.zova", .{span(partial)})) |path| { + _ = operationsRemoveClosedDatabaseSidecars(path); + _ = operationsRemoveClosedWriterLock(path); + _ = c.cbm_unlink(path); + } + _ = c.rmdir(partial); +} + +// ── Status ────────────────────────────────────────────────────── + +pub export fn cbm_zova_operation_code_name(code: Code) Str { + return switch (code) { + OK => "ok", + NOOP => "noop", + INVALID => "invalid", + BUSY => "busy", + INCOMPATIBLE => "incompatible", + VERIFY_FAILED => "verify_failed", + DISK_REFUSED => "disk_refused", + CONFIRMATION_REQUIRED => "confirmation_required", + WORKSPACE_REBUILD_REQUIRED => "workspace_rebuild_required", + WHOLE_FILE_RECOVERY_REQUIRED => "whole_file_recovery_required", + else => "unknown", + }; +} + +pub export fn cbm_zova_database_status(path: ?Str, out: ?*Report) Code { + _ = operationsReport(out, INVALID, "status", "invalid_argument"); + const live = nonEmpty(path) orelse return INVALID; + const report = out orelse return INVALID; + if (!with_zova) return operationsReport(report, INCOMPATIBLE, "status", "zova_disabled"); + + const database_size = if (operationsPathIsRegularNoSymlink(live)) operationsFileSize(live) else null; + if (database_size == null or database_size.? < 0) + return operationsReport(report, INVALID, "status", "database_not_regular"); + var wal_buf: [PATH_CAP]u8 = undefined; + const wal_path = operationsPath(&wal_buf, "{s}-wal", .{span(live)}) orelse + return operationsReport(report, INVALID, "status", "path_too_long"); + var wal_bytes: u64 = 0; + if (operationsLstat(wal_path)) |wal_stat| { + if (!operationsIsRegular(&wal_stat) or wal_stat.st_size < 0) + return operationsReport(report, INVALID, "status", "wal_not_regular"); + wal_bytes = operationsBytes(wal_stat.st_size); + } + const free_bytes = operationsFreeBytes(live) catch |err| + return operationsReport(report, VERIFY_FAILED, "status", switch (err) { + error.Unavailable => "free_space_unavailable", + error.Overflow => "free_space_overflow", + }); + + const started = operationsNow(); + const db = operationsOpenReadOnly(live) orelse + return operationsReport(report, VERIFY_FAILED, "status", "database_open_failed"); + var schema_version: i64 = 0; + var invalid_generations: i64 = 0; + var page_size: i64 = -1; + var page_count: i64 = -1; + var freelist_count: i64 = -1; + const schema_read = operationsQueryI64(db, schema_version_sql, &schema_version); + if (!schema_read or schema_version != SCHEMA_VERSION) { + _ = c.zova_database_close(db); + if (schema_read and schema_version > SCHEMA_VERSION) { + _ = operationsReport(report, INCOMPATIBLE, "status", "schema_version_incompatible"); + report.schema_version = std.math.cast(c_int, schema_version) orelse 0; + return INCOMPATIBLE; + } + return operationsReport(report, VERIFY_FAILED, "status", "current_schema_required"); + } + const verified = c.cbm_zova_user_database_schema_is_current(db) == 0 and + operationsQuickCheck(db) and operationsForeignKeysOk(db) and + operationsQueryI64(db, invalid_generations_sql, &invalid_generations) and + invalid_generations == 0 and + operationsQueryI64(db, "PRAGMA page_size", &page_size) and + operationsQueryI64(db, "PRAGMA page_count", &page_count) and + operationsQueryI64(db, "PRAGMA freelist_count", &freelist_count); + _ = c.zova_database_close(db); + if (!verified or page_size <= 0 or page_count < 0 or freelist_count < 0) + return operationsReport(report, VERIFY_FAILED, "status", "database_verification_failed"); + const unsigned_page_size: u64 = @intCast(page_size); + const unsigned_freelist_count: u64 = @intCast(freelist_count); + const reclaimable_bytes = std.math.mul(u64, unsigned_page_size, unsigned_freelist_count) catch + return operationsReport(report, VERIFY_FAILED, "status", "reclaimable_bytes_overflow"); + const finished = operationsNow(); + _ = operationsReport(report, OK, "status", "ok"); + report.schema_version = @intCast(schema_version); + report.database_bytes = operationsBytes(database_size.?); + report.wal_bytes = wal_bytes; + report.free_bytes = free_bytes; + report.page_size = unsigned_page_size; + report.page_count = @intCast(page_count); + report.freelist_count = unsigned_freelist_count; + report.reclaimable_bytes = reclaimable_bytes; + report.elapsed_ms = operationsElapsedMs(started, finished); + return OK; +} + +// ── Workspace delete ──────────────────────────────────────────── + +fn workspaceDeleteBody(path: ?Str, workspace_id: ?Str, confirmation: ?Str, out: ?*Report) Code { + _ = operationsReport(out, INVALID, "delete_workspace", "invalid_argument"); + const live = nonEmpty(path) orelse return INVALID; + const workspace = nonEmpty(workspace_id) orelse return INVALID; + const report = out orelse return INVALID; + const confirmed = nonEmpty(confirmation) orelse + return operationsReport(report, CONFIRMATION_REQUIRED, "delete_workspace", "confirmation_required"); + if (!std.mem.eql(u8, span(workspace), span(confirmed))) + return operationsReport(report, CONFIRMATION_REQUIRED, "delete_workspace", "confirmation_mismatch"); + if (c.cbm_zova_workspace_id_validate(workspace) != 0) return INVALID; + if (!with_zova) return operationsReport(report, INCOMPATIBLE, "delete_workspace", "zova_disabled"); + + const started = operationsNow(); + var before = std.mem.zeroes(Report); + const status = cbm_zova_database_status(live, &before); + if (status != OK) return operationsReport(report, status, "delete_workspace", "status_failed"); + var snapshot = std.mem.zeroes(Snapshot); + if (c.cbm_zova_repository_export_snapshot(live, workspace, &snapshot) != 0) { + const finished = operationsNow(); + _ = operationsReport(report, NOOP, "delete_workspace", "already_absent"); + operationsSetWorkspaceId(report, workspace); + operationsCopyStatusMetrics(report, &before); + report.reclaimable_bytes = 0; + report.elapsed_ms = operationsElapsedMs(started, finished); + return NOOP; + } + const generation = snapshot.generation; + c.cbm_zova_workspace_snapshot_free(&snapshot); + if (c.cbm_zova_user_database_delete_workspace(live, workspace) != 0) + return operationsReport(report, VERIFY_FAILED, "delete_workspace", "delete_failed"); + var after = std.mem.zeroes(Report); + if (cbm_zova_database_status(live, &after) != OK) + return operationsReport(report, VERIFY_FAILED, "delete_workspace", "post_delete_status_failed"); + const finished = operationsNow(); + _ = operationsReport(report, OK, "delete_workspace", "ok"); + operationsSetWorkspaceId(report, workspace); + report.generation = generation; + operationsCopyStatusMetrics(report, &after); + report.reclaimable_bytes = after.reclaimable_bytes -| before.reclaimable_bytes; + report.elapsed_ms = operationsElapsedMs(started, finished); + return OK; +} + +// ── Database replacement ──────────────────────────────────────── + +fn operationsCheckpointTruncate(db: *Db) bool { + const statement = operationsPrepare(db, "PRAGMA wal_checkpoint(TRUNCATE)", null) orelse return false; + defer _ = c.zova_statement_finalize(statement); + var busy: i64 = -1; + var log_frames: i64 = -1; + var checkpointed_frames: i64 = -1; + return operationsStepIs(statement, c.ZOVA_STEP_ROW) and + operationsColumnI64(statement, 0, &busy) and + operationsColumnI64(statement, 1, &log_frames) and + operationsColumnI64(statement, 2, &checkpointed_frames) and + busy == 0 and log_frames == checkpointed_frames; +} + +fn operationsRawSqliteCheckpointTruncate(path: Str) bool { + var db: ?*c.sqlite3 = null; + var log_frames: c_int = 0; + var checkpointed_frames: c_int = 0; + var rc = c.sqlite3_open_v2(path, &db, c.SQLITE_OPEN_READWRITE, null); + if (rc == c.SQLITE_OK) _ = c.sqlite3_busy_timeout(db, 5000); + if (rc == c.SQLITE_OK) + rc = c.sqlite3_wal_checkpoint_v2(db, null, c.SQLITE_CHECKPOINT_TRUNCATE, &log_frames, &checkpointed_frames); + if (db != null) _ = c.sqlite3_close(db); + return rc == c.SQLITE_OK and checkpointed_frames == log_frames; +} + +/// Compaction is skipped when it would reclaim less than 64 MiB and less +/// than a tenth of the file. +fn operationsCompactBelowThreshold(status: *const Report) bool { + const absolute_threshold: u64 = 64 * 1024 * 1024; + const below_absolute = status.reclaimable_bytes < absolute_threshold; + const quotient = status.database_bytes / 10; + const remainder = status.database_bytes % 10; + const below_ratio = status.reclaimable_bytes < quotient or + (status.reclaimable_bytes == quotient and remainder != 0); + return below_absolute and below_ratio; +} + +/// Which of the three files of a same-directory replacement exist. +const ReplacementFiles = struct { + live: PathState, + temporary: PathState, + recovery: PathState, + + fn read(live: Str, temporary: Str, recovery: Str) ReplacementFiles { + return .{ + .live = operationsRestorePathState(live), + .temporary = operationsRestorePathState(temporary), + .recovery = operationsRestorePathState(recovery), + }; + } + + fn are(self: ReplacementFiles, live: PathState, temporary: PathState, recovery: PathState) bool { + return self.live == live and self.temporary == temporary and self.recovery == recovery; + } + + /// The states a replacement starts or resumes from: not started, the + /// temporary written, the live file moved to recovery, the temporary + /// moved to live. + fn resumable(self: ReplacementFiles) bool { + return self.are(.regular, .absent, .absent) or self.are(.regular, .regular, .absent) or + self.are(.absent, .regular, .regular) or self.are(.regular, .absent, .regular); + } + + fn anyOther(self: ReplacementFiles) bool { + return self.live == .other or self.temporary == .other or self.recovery == .other; + } +}; + +const ReplacementOptions = struct { + operation: []const u8, + after_temp_verification: []const u8, + after_live_to_recovery: []const u8, + after_temp_to_live: []const u8, + before_recovery_cleanup: []const u8, + remove_sidecars: bool, + require_source_verified: bool, + keep_recovery: bool = false, +}; + +/// `interrupted` is a test fault, which has already filled the report. +const Replacement = enum { done, failed, interrupted }; + +/// Complete or resume a verified same-directory database replacement. +fn operationsReplaceVerifiedDatabase( + live: Str, + temporary: Str, + recovery: Str, + parent: Str, + options: *const ReplacementOptions, + out_schema_version: ?*c_int, + out: ?*Report, +) Replacement { + var files = ReplacementFiles.read(live, temporary, recovery); + if (!files.are(.regular, .regular, .absent) and !files.are(.absent, .regular, .regular) and + !files.are(.regular, .absent, .regular)) + return .failed; + + var schema_version: c_int = 0; + if (files.are(.regular, .regular, .absent)) { + if (!operationsVerifyDatabase(temporary, &schema_version)) return .failed; + if (operationsReplacementFault(options.operation, options.after_temp_verification, out)) + return .interrupted; + if ((options.require_source_verified and !operationsVerifyDatabase(live, null)) or + (options.remove_sidecars and + (!operationsRemoveClosedDatabaseSidecars(live) or + !operationsRemoveClosedDatabaseSidecars(temporary))) or + c.rename(live, recovery) != 0 or !operationsSyncDirectory(parent)) + return .failed; + files.live = .absent; + files.recovery = .regular; + if (operationsReplacementFault(options.operation, options.after_live_to_recovery, out)) + return .interrupted; + } + if (files.are(.absent, .regular, .regular)) { + if (!operationsVerifyDatabase(temporary, &schema_version) or + (options.require_source_verified and !operationsVerifyDatabase(recovery, null)) or + (options.remove_sidecars and + (!operationsRemoveClosedDatabaseSidecars(temporary) or + !operationsRemoveClosedDatabaseSidecars(recovery) or + !operationsRemoveClosedDatabaseSidecars(live))) or + c.rename(temporary, live) != 0 or !operationsSyncDirectory(parent)) + return .failed; + files.live = .regular; + files.temporary = .absent; + if (operationsReplacementFault(options.operation, options.after_temp_to_live, out)) + return .interrupted; + } + if (!files.are(.regular, .absent, .regular) or !operationsVerifyDatabase(live, &schema_version)) + return .failed; + if (operationsReplacementFault(options.operation, options.before_recovery_cleanup, out)) + return .interrupted; + if (!options.keep_recovery and + ((options.remove_sidecars and !operationsRemoveClosedDatabaseSidecars(recovery)) or + c.cbm_unlink(recovery) != 0 or !operationsSyncDirectory(parent))) + return .failed; + if (out_schema_version) |version| version.* = schema_version; + return .done; +} + +// ── Compact ───────────────────────────────────────────────────── + +pub export fn cbm_zova_database_compact(path: ?Str, out: ?*Report) Code { + _ = operationsReport(out, INVALID, "compact", "invalid_argument"); + const live = nonEmpty(path) orelse return INVALID; + const report = out orelse return INVALID; + if (!with_zova) return INCOMPATIBLE; + + var temporary_buf: [PATH_CAP]u8 = undefined; + var recovery_buf: [PATH_CAP]u8 = undefined; + var parent_buf: [PATH_CAP]u8 = undefined; + const stem = operationsZovaStem(span(live)) orelse return INVALID; + const temporary = operationsPath(&temporary_buf, "{s}.compact.tmp.zova", .{stem}) orelse return INVALID; + const recovery = operationsPath(&recovery_buf, "{s}.compact.recovery.zova", .{stem}) orelse return INVALID; + const parent = operationsParentDirectory(span(live), &parent_buf) orelse return INVALID; + + var guard = std.mem.zeroes(WriterGuard); + if (c.cbm_zova_writer_guard_try_acquire(live, &guard) != 0) + return operationsReport(report, BUSY, "compact", "writer_busy"); + defer c.cbm_zova_writer_guard_release(&guard); + const started = operationsNow(); + const files = ReplacementFiles.read(live, temporary, recovery); + if (!files.resumable()) + return operationsReport(report, VERIFY_FAILED, "compact", "invalid_recovery_state"); + + if (files.are(.regular, .absent, .absent)) { + var before = std.mem.zeroes(Report); + const status_code = cbm_zova_database_status(live, &before); + if (status_code != OK) return operationsReport(report, status_code, "compact", "status_failed"); + if (operationsCompactBelowThreshold(&before)) { + _ = operationsReport(report, NOOP, "compact", "below_threshold"); + operationsCopyStatusMetrics(report, &before); + report.reclaimable_bytes = before.reclaimable_bytes; + return NOOP; + } + const free_override: [*c]const u8 = c.getenv("CBM_ZOVA_TEST_COMPACT_FREE_BYTES"); + if (free_override != null and free_override[0] != 0) { + var overridden: c_ulonglong = 0; + var trailing: u8 = 0; + if (c.sscanf(free_override, "%llu%c", &overridden, &trailing) != 1) + return operationsReport(report, VERIFY_FAILED, "compact", "invalid_test_free_bytes"); + before.free_bytes = overridden; + } + const reserve: u64 = 1024 * 1024 * 1024; + const guard_bytes: u64 = 8 * 1024 * 1024 * 1024; + const with_reserve = std.math.add(u64, before.database_bytes, reserve) catch + return operationsReport(report, DISK_REFUSED, "compact", "required_space_overflow"); + if (before.free_bytes < @max(with_reserve, guard_bytes)) { + _ = operationsReport(report, DISK_REFUSED, "compact", "insufficient_disk"); + operationsCopyStatusMetrics(report, &before); + report.reclaimable_bytes = before.reclaimable_bytes; + return DISK_REFUSED; + } + + const compacted = if (operationsOpen(live, 0, 0, null)) |db| compacted: { + defer _ = c.zova_database_close(db); + break :compacted operationsCheckpointTruncate(db) and + c.zova_database_compact(&c.zova_database_compact_request{ + .db = db, + .destination_path = temporary, + .flags = 0, + }) == c.ZOVA_OK; + } else false; + if (!compacted) { + _ = operationsRemoveClosedDatabaseSidecars(temporary); + _ = c.cbm_unlink(temporary); + return operationsReport(report, BUSY, "compact", "checkpoint_or_compact_busy"); + } + if (!operationsRemoveClosedDatabaseSidecars(live) or + !operationsRemoveClosedDatabaseSidecars(temporary)) + return operationsReport(report, VERIFY_FAILED, "compact", "sidecar_cleanup_failed"); + if (operationsReplacementFault("compact", "compact_after_temp_creation", report)) + return VERIFY_FAILED; + } + + const replacement = ReplacementOptions{ + .operation = "compact", + .after_temp_verification = "compact_after_temp_verification", + .after_live_to_recovery = "compact_after_live_to_recovery", + .after_temp_to_live = "compact_after_temp_to_live", + .before_recovery_cleanup = "compact_before_recovery_cleanup", + .remove_sidecars = true, + .require_source_verified = true, + }; + var after = std.mem.zeroes(Report); + switch (operationsReplaceVerifiedDatabase(live, temporary, recovery, parent, &replacement, null, report)) { + .interrupted => return VERIFY_FAILED, + .failed => return operationsReport(report, VERIFY_FAILED, "compact", "replacement_failed"), + .done => {}, + } + if (cbm_zova_database_status(live, &after) != OK) + return operationsReport(report, VERIFY_FAILED, "compact", "replacement_failed"); + const finished = operationsNow(); + _ = operationsReport(report, OK, "compact", "ok"); + operationsCopyStatusMetrics(report, &after); + report.reclaimable_bytes = after.reclaimable_bytes; + report.elapsed_ms = operationsElapsedMs(started, finished); + return OK; +} + +// ── Format migration ──────────────────────────────────────────── + +/// Zova 1.2.0 stages a migration in ".migration-recovery/" and +/// removes it on success, but for a WAL database the staged copy's empty -wal +/// and -shm stay behind and keep the directory alive. Remove exactly those. +fn operationsRemoveMigrationStaging(destination: Str) void { + const path = span(destination); + const name = if (std.mem.lastIndexOfScalar(u8, path, '/')) |slash| path[slash + 1 ..] else path; + var directory: [PATH_CAP + 32]u8 = undefined; + var sidecar: [PATH_CAP + 32 + 512]u8 = undefined; + _ = interop.bprint(&directory, "{s}.migration-recovery", .{path}); + const staging = sliceTo(&directory, 0); + _ = interop.bprint(&sidecar, "{s}/{s}-wal", .{ staging, name }); + _ = c.cbm_unlink(&sidecar); + _ = interop.bprint(&sidecar, "{s}/{s}-shm", .{ staging, name }); + _ = c.cbm_unlink(&sidecar); + _ = c.cbm_rmdir(&directory); +} + +pub export fn cbm_zova_database_migrate_format(path: ?Str, out: ?*Report) Code { + const operation = "migrate-format"; + _ = operationsReport(out, INVALID, operation, "invalid_argument"); + const live = nonEmpty(path) orelse return INVALID; + const report = out orelse return INVALID; + if (!with_zova) return INCOMPATIBLE; + + var temporary_buf: [PATH_CAP]u8 = undefined; + var recovery_buf: [PATH_CAP]u8 = undefined; + var parent_buf: [PATH_CAP]u8 = undefined; + const stem = operationsZovaStem(span(live)) orelse return INVALID; + const temporary = operationsPath(&temporary_buf, "{s}.format.tmp.zova", .{stem}) orelse return INVALID; + const recovery = operationsPath(&recovery_buf, "{s}.format.recovery.zova", .{stem}) orelse return INVALID; + const parent = operationsParentDirectory(span(live), &parent_buf) orelse return INVALID; + + var guard = std.mem.zeroes(WriterGuard); + if (c.cbm_zova_writer_guard_try_acquire(live, &guard) != 0) + return operationsReport(report, BUSY, operation, "writer_busy"); + defer c.cbm_zova_writer_guard_release(&guard); + const files = ReplacementFiles.read(live, temporary, recovery); + if (!files.resumable()) + return operationsReport(report, VERIFY_FAILED, operation, "invalid_recovery_state"); + + if (files.are(.regular, .absent, .absent)) { + const format = c.cbm_zova_database_format_status(live); + if (format == c.CBM_ZOVA_DATABASE_COMPATIBLE) + return operationsReport(report, NOOP, operation, "current_format"); + if (format != c.CBM_ZOVA_DATABASE_MIGRATION_REQUIRED) + return operationsReport(report, INCOMPATIBLE, operation, "unsupported_format"); + // Zova writes the migrated copy next to the source, so the directory + // briefly holds both files. + const reserve: u64 = 1024 * 1024 * 1024; + const source_stat = operationsLstat(live); + const free_bytes = if (source_stat != null and source_stat.?.st_size >= 0) + operationsFreeBytes(live) catch null + else + null; + if (free_bytes == null) + return operationsReport(report, VERIFY_FAILED, operation, "free_space_unavailable"); + if (free_bytes.? < operationsBytes(source_stat.?.st_size) + reserve) + return operationsReport(report, DISK_REFUSED, operation, "insufficient_disk"); + // The replacement drops the source sidecars, so the WAL must be empty. + if (!operationsRawSqliteCheckpointTruncate(live)) + return operationsReport(report, BUSY, operation, "checkpoint_busy"); + var message = std.mem.zeroes(c.zova_message); + const migrated = c.zova_database_migrate(&c.zova_database_migrate_request{ + .source_path = live, + .destination_path = temporary, + .flags = 0, + .out_error_message = &message, + }); + if (migrated != c.ZOVA_OK) { + interop.log(c.CBM_LOG_ERROR, "zova.migrate_format", .{ + "path", live, + "error", if (message.data != null) @as(Str, @ptrCast(message.data)) else "unknown", + }); + c.zova_message_free(&message); + _ = operationsRemoveClosedDatabaseSidecars(temporary); + _ = c.cbm_unlink(temporary); + return operationsReport(report, VERIFY_FAILED, operation, "zova_migrate_failed"); + } + c.zova_message_free(&message); + operationsRemoveMigrationStaging(temporary); + if (operationsReplacementFault(operation, "migrate_format_after_temp_creation", report)) + return report.code; + } + + const replacement = ReplacementOptions{ + .operation = operation, + .after_temp_verification = "migrate_format_after_temp_verification", + .after_live_to_recovery = "migrate_format_after_live_to_recovery", + .after_temp_to_live = "migrate_format_after_temp_to_live", + .before_recovery_cleanup = "migrate_format_before_recovery_cleanup", + .remove_sidecars = true, + // The current SDK cannot open the old-format source to verify it. + .require_source_verified = false, + }; + var after = std.mem.zeroes(Report); + switch (operationsReplaceVerifiedDatabase(live, temporary, recovery, parent, &replacement, null, report)) { + .interrupted => return report.code, + .failed => return operationsReport(report, VERIFY_FAILED, operation, "replacement_failed"), + .done => {}, + } + if (cbm_zova_database_status(live, &after) != OK) + return operationsReport(report, VERIFY_FAILED, operation, "replacement_failed"); + _ = operationsReport(report, OK, operation, "ok"); + operationsCopyStatusMetrics(report, &after); + return OK; +} + +// ── Health and recovery ───────────────────────────────────────── + +fn operationsWorkspaceHealthRow(db: *Db, workspace_id: Str, out_generation: *i64, out_rebuild: *bool) bool { + const statement = operationsPrepare( + db, + "SELECT r.active_generation,COALESCE(h.state,'healthy') " ++ + "FROM cbm_workspace_registry r LEFT JOIN cbm_workspace_health_v1 h " ++ + "ON h.workspace_key=r.workspace_key WHERE r.workspace_id=?1", + null, + ) orelse return false; + defer _ = c.zova_statement_finalize(statement); + if (!operationsBindText(statement, 1, workspace_id)) return false; + var state = std.mem.zeroes(c.zova_text); + defer c.zova_text_free(&state); + if (!operationsStepIs(statement, c.ZOVA_STEP_ROW) or + !operationsColumnI64(statement, 0, out_generation) or + !operationsColumnText(statement, 1, &state)) + return false; + out_rebuild.* = std.mem.eql(u8, state.data[0..state.len], "rebuild_required"); + return true; +} + +fn operationsMarkWorkspaceRebuildRequired(path: Str, workspace_id: Str, generation: i64, reason: Str) bool { + var guard = std.mem.zeroes(WriterGuard); + if (c.cbm_zova_writer_guard_acquire(path, &guard) != 0) return false; + defer c.cbm_zova_writer_guard_release(&guard); + var opened: ?*Db = null; + var message = std.mem.zeroes(c.zova_message); + const open_status = c.zova_database_open(&c.zova_database_open_request{ + .path = path, + .out_db = &opened, + .out_error_message = &message, + }); + c.zova_message_free(&message); + const db = opened orelse return false; + defer _ = c.zova_database_close(db); + const transaction = c.zova_database_simple_request{ .db = db }; + var ok = open_status == c.ZOVA_OK and c.zova_database_begin_immediate(&transaction) == c.ZOVA_OK; + if (ok) ok = written: { + const statement = operationsPrepare( + db, + "INSERT INTO cbm_workspace_health_v1" ++ + "(workspace_key,state,reason,checked_generation,checked_at) " ++ + "SELECT workspace_key,'rebuild_required',?2,?3," ++ + "strftime('%Y-%m-%dT%H:%M:%fZ','now') " ++ + "FROM cbm_workspace_registry WHERE workspace_id=?1 " ++ + "ON CONFLICT(workspace_key) DO UPDATE SET " ++ + "state=excluded.state,reason=excluded.reason," ++ + "checked_generation=excluded.checked_generation," ++ + "checked_at=excluded.checked_at", + null, + ) orelse break :written false; + defer _ = c.zova_statement_finalize(statement); + break :written operationsBindText(statement, 1, workspace_id) and + operationsBindText(statement, 2, reason) and + operationsBindI64(statement, 3, generation) and + operationsStepIs(statement, c.ZOVA_STEP_DONE); + }; + if (ok) { + ok = c.zova_database_commit(&transaction) == c.ZOVA_OK; + } else { + _ = c.zova_database_rollback(&transaction); + } + return ok; +} + +fn operationsWorkspaceProject(path: Str, workspace_id: Str, out: []u8) bool { + const db = operationsOpenReadOnly(path) orelse return false; + defer _ = c.zova_database_close(db); + const statement = operationsPrepare( + db, + "SELECT p.project FROM cbm_projects_v1 p " ++ + "JOIN cbm_workspace_registry r USING(workspace_key) " ++ + "WHERE r.workspace_id=?1", + null, + ) orelse return false; + defer _ = c.zova_statement_finalize(statement); + if (!operationsBindText(statement, 1, workspace_id)) return false; + var project = std.mem.zeroes(c.zova_text); + defer c.zova_text_free(&project); + return operationsStepIs(statement, c.ZOVA_STEP_ROW) and + operationsColumnText(statement, 0, &project) and operationsTextInto(project, out); +} + +/// Moves a damaged database and its -wal and -shm aside as +/// ".corrupt.zova", under the writer gate. +fn operationsQuarantineDatabase(path: Str) bool { + var quarantine_buf: [PATH_CAP]u8 = undefined; + var parent_buf: [PATH_CAP]u8 = undefined; + const stem = operationsZovaStem(span(path)) orelse return false; + const quarantine = operationsPath(&quarantine_buf, "{s}.corrupt.zova", .{stem}) orelse return false; + const parent = operationsParentDirectory(span(path), &parent_buf) orelse return false; + if (operationsRestorePathState(path) != .regular or operationsRestorePathState(quarantine) != .absent) + return false; + var source_bufs: [2][PATH_CAP]u8 = undefined; + var quarantine_bufs: [2][PATH_CAP]u8 = undefined; + var source_sidecars: [2]Str = undefined; + var quarantine_sidecars: [2]Str = undefined; + var present = [2]bool{ false, false }; + for ([_][]const u8{ "-wal", "-shm" }, 0..) |suffix, i| { + source_sidecars[i] = operationsPath(&source_bufs[i], "{s}{s}", .{ span(path), suffix }) orelse return false; + quarantine_sidecars[i] = operationsPath(&quarantine_bufs[i], "{s}{s}", .{ span(quarantine), suffix }) orelse return false; + const source_state = operationsRestorePathState(source_sidecars[i]); + if (source_state == .other or operationsRestorePathState(quarantine_sidecars[i]) != .absent) return false; + present[i] = source_state == .regular; + } + var guard = std.mem.zeroes(WriterGuard); + if (c.cbm_zova_writer_guard_acquire(path, &guard) != 0) return false; + defer c.cbm_zova_writer_guard_release(&guard); + var moved = [2]bool{ false, false }; + var ok = true; + for (0..2) |i| { + if (!present[i]) continue; + if (c.rename(source_sidecars[i], quarantine_sidecars[i]) != 0) { + ok = false; + break; + } + moved[i] = true; + } + if (ok and c.rename(path, quarantine) != 0) ok = false; + if (ok and !operationsSyncDirectory(parent)) ok = false; + if (!ok) { + var i: usize = 2; + while (i > 0) { + i -= 1; + if (moved[i]) _ = c.rename(quarantine_sidecars[i], source_sidecars[i]); + } + } + return ok; +} + +/// Reads the registry's workspace ids, in order, into `ids`. +fn operationsReadWorkspaceIds(db: *Db, ids: [][ID_MAX]u8) bool { + if (ids.len == 0) return true; + const statement = operationsPrepare( + db, + "SELECT workspace_id FROM cbm_workspace_registry ORDER BY workspace_id", + null, + ) orelse return false; + defer _ = c.zova_statement_finalize(statement); + for (ids) |*id| { + if (!operationsStepIs(statement, c.ZOVA_STEP_ROW)) return false; + var value = std.mem.zeroes(c.zova_text); + defer c.zova_text_free(&value); + if (!operationsColumnText(statement, 0, &value) or !operationsTextInto(value, id)) return false; + } + return true; +} + +/// Health of the whole database: every workspace is checked in turn and the +/// first one that is not healthy decides the result. Closes `db`. +fn operationsHealthEveryWorkspace(path: Str, db: *Db, schema_version: i64, out_class: *HealthClass, out: *Report) Code { + var workspace_count: i64 = 0; + if (!operationsQueryI64(db, workspace_count_sql, &workspace_count) or workspace_count < 0 or + @as(u64, @intCast(workspace_count)) > std.math.maxInt(usize) / ID_MAX) + { + _ = c.zova_database_close(db); + return operationsReport(out, WHOLE_FILE_RECOVERY_REQUIRED, "health", "workspace_inventory_invalid"); + } + const count: usize = @intCast(workspace_count); + const allocated: ?[*][ID_MAX]u8 = if (count > 0) interop.callocN([ID_MAX]u8, count) else null; + defer c.free(@ptrCast(allocated)); + const ids: ?[][ID_MAX]u8 = if (count == 0) &.{} else if (allocated) |rows| rows[0..count] else null; + const inventory_ok = if (ids) |rows| operationsReadWorkspaceIds(db, rows) else false; + _ = c.zova_database_close(db); + if (!inventory_ok) + return operationsReport(out, WHOLE_FILE_RECOVERY_REQUIRED, "health", "workspace_inventory_invalid"); + for (ids.?) |*id| { + var workspace_health: HealthClass = c.CBM_ZOVA_HEALTH_OK; + var workspace_report = std.mem.zeroes(Report); + const code = cbm_zova_database_health(path, @ptrCast(id), &workspace_health, &workspace_report); + if (code != OK) { + out.* = workspace_report; + out_class.* = workspace_health; + return code; + } + } + out_class.* = c.CBM_ZOVA_HEALTH_OK; + _ = operationsReport(out, OK, "health", "ok"); + out.schema_version = @intCast(schema_version); + return OK; +} + +/// Reports that `workspace_id` needs a rebuild for `reason`. +fn operationsRebuildRequired(out: *Report, out_class: *HealthClass, workspace_id: Str, reason: []const u8) Code { + _ = operationsReport(out, WORKSPACE_REBUILD_REQUIRED, "health", reason); + operationsSetWorkspaceId(out, workspace_id); + out_class.* = c.CBM_ZOVA_HEALTH_WORKSPACE_REBUILD; + return WORKSPACE_REBUILD_REQUIRED; +} + +pub export fn cbm_zova_database_health(path: ?Str, workspace_id: ?Str, out_class: ?*HealthClass, out: ?*Report) Code { + _ = operationsReport(out, INVALID, "health", "invalid_argument"); + const live = nonEmpty(path) orelse return INVALID; + const class = out_class orelse return INVALID; + const report = out orelse return INVALID; + if (workspace_id != null and c.cbm_zova_workspace_id_validate(workspace_id) != 0) return INVALID; + if (!with_zova) return INCOMPATIBLE; + + class.* = c.CBM_ZOVA_HEALTH_WHOLE_FILE_RECOVERY; + var open_status: c.zova_status = c.ZOVA_OK; + const opened = operationsOpen(live, c.ZOVA_OPEN_READ_ONLY, 5000, &open_status); + var check_status: c.zova_status = c.ZOVA_OK; + var schema_version: i64 = 0; + const shared_ok = if (opened) |db| + operationsQuickCheckStatus(db, &check_status) and + operationsQueryI64(db, schema_version_sql, &schema_version) and + schema_version == SCHEMA_VERSION and + c.cbm_zova_user_database_schema_is_current(db) == 0 + else + false; + if (!shared_ok) { + if (opened) |db| _ = c.zova_database_close(db); + // Unreadable right now is not damage; never send it to recovery. + if (operationsStatusIsTransient(if (opened != null) check_status else open_status)) + return operationsReport(report, BUSY, "health", "database_unavailable"); + _ = operationsReport(report, WHOLE_FILE_RECOVERY_REQUIRED, "health", "shared_database_invalid"); + report.schema_version = @truncate(schema_version); + return WHOLE_FILE_RECOVERY_REQUIRED; + } + const db = opened.?; + if (!operationsForeignKeysOk(db)) { + _ = c.zova_database_close(db); + _ = operationsReport(report, WHOLE_FILE_RECOVERY_REQUIRED, "health", "foreign_key_violation"); + report.schema_version = @intCast(schema_version); + return WHOLE_FILE_RECOVERY_REQUIRED; + } + const workspace = workspace_id orelse + return operationsHealthEveryWorkspace(live, db, schema_version, class, report); + + var generation: i64 = 0; + var already_rebuild = false; + const row_read = operationsWorkspaceHealthRow(db, workspace, &generation, &already_rebuild); + _ = c.zova_database_close(db); + if (!row_read) return operationsRebuildRequired(report, class, workspace, "workspace_generation_invalid"); + if (already_rebuild) { + _ = operationsRebuildRequired(report, class, workspace, "workspace_rebuild_required"); + report.generation = generation; + report.schema_version = @intCast(schema_version); + return WORKSPACE_REBUILD_REQUIRED; + } + const mismatch: ?Str = mismatch: { + var snapshot = std.mem.zeroes(Snapshot); + if (c.cbm_zova_repository_export_snapshot(live, workspace, &snapshot) != 0) + break :mismatch "workspace_public_state_invalid"; + defer c.cbm_zova_workspace_snapshot_free(&snapshot); + const input = operationsSnapshotInput(&snapshot); + var readback = std.mem.zeroes(GenerationResult); + if (c.cbm_zova_workspace_generation_digest_input(workspace, &input, &readback) != 0) + break :mismatch "workspace_digest_mismatch"; + readback.generation = snapshot.generation; + _ = interop.bprint(&readback.workspace_id, "{s}", .{span(workspace)}); + break :mismatch if (operationsGenerationMatches(&readback, &snapshot.integrity)) null else "workspace_digest_mismatch"; + }; + if (mismatch) |reason| { + if (!operationsMarkWorkspaceRebuildRequired(live, workspace, generation, reason)) + return operationsReport(report, VERIFY_FAILED, "health", "health_state_write_failed"); + _ = operationsRebuildRequired(report, class, workspace, span(reason)); + report.generation = generation; + report.schema_version = @intCast(schema_version); + return WORKSPACE_REBUILD_REQUIRED; + } + _ = operationsReport(report, OK, "health", "ok"); + operationsSetWorkspaceId(report, workspace); + report.generation = generation; + report.schema_version = @intCast(schema_version); + class.* = c.CBM_ZOVA_HEALTH_OK; + return OK; +} + +fn workspaceRecoverBody(path: ?Str, workspace_id: ?Str, repo_path: ?Str, out: ?*Report) Code { + const operation = "recover_workspace"; + _ = operationsReport(out, INVALID, operation, "invalid_argument"); + const live = nonEmpty(path) orelse return INVALID; + if (c.cbm_zova_workspace_id_validate(workspace_id) != 0) return INVALID; + const workspace = workspace_id orelse return INVALID; + const repo = nonEmpty(repo_path) orelse return INVALID; + const report = out orelse return INVALID; + if (!with_zova) return INCOMPATIBLE; + + var expected_workspace: [ID_MAX]u8 = undefined; + var user_path: [PATH_CAP]u8 = undefined; + if (c.cbm_zova_workspace_id_for_root(repo, &expected_workspace, expected_workspace.len) != 0 or + !bufEql(&expected_workspace, span(workspace)) or + c.cbm_zova_user_database_path(&user_path, user_path.len) != 0 or + !bufEql(&user_path, span(live))) + return operationsReport(report, INVALID, operation, "identity_or_route_mismatch"); + var health: HealthClass = c.CBM_ZOVA_HEALTH_OK; + var health_report = std.mem.zeroes(Report); + var health_code = cbm_zova_database_health(live, workspace, &health, &health_report); + if (health_code == WHOLE_FILE_RECOVERY_REQUIRED and health == c.CBM_ZOVA_HEALTH_WHOLE_FILE_RECOVERY) { + // Moving the file aside needs the exclusive barrier, which the caller + // takes after releasing this run's shared hold. + _ = operationsReport(report, WHOLE_FILE_RECOVERY_REQUIRED, operation, "whole_file_recovery_required"); + operationsSetWorkspaceId(report, workspace); + return WHOLE_FILE_RECOVERY_REQUIRED; + } + if (health_code != WORKSPACE_REBUILD_REQUIRED or health != c.CBM_ZOVA_HEALTH_WORKSPACE_REBUILD) { + if (health_code == OK) return operationsReport(report, NOOP, operation, "workspace_healthy"); + return operationsReport(report, health_code, operation, sliceTo(&health_report.reason, 0)); + } + var project: [256]u8 = undefined; + const pipeline = if (operationsWorkspaceProject(live, workspace, &project)) + c.cbm_pipeline_new(repo, null, c.CBM_MODE_FULL) + else + null; + if (pipeline == null or !c.cbm_pipeline_set_project_name(pipeline, &project)) { + if (pipeline != null) c.cbm_pipeline_free(pipeline); + return operationsReport(report, VERIFY_FAILED, operation, "pipeline_create_failed"); + } + const run_rc = c.cbm_pipeline_run(pipeline); + c.cbm_pipeline_free(pipeline); + if (run_rc != 0) { + _ = operationsReport(report, VERIFY_FAILED, operation, "source_rebuild_failed"); + operationsSetWorkspaceId(report, workspace); + report.generation = health_report.generation; + return VERIFY_FAILED; + } + health_code = cbm_zova_database_health(live, workspace, &health, &health_report); + if (health_code != OK or health != c.CBM_ZOVA_HEALTH_OK) + return operationsReport(report, VERIFY_FAILED, operation, "post_rebuild_health_failed"); + _ = operationsReport(report, OK, operation, "ok"); + operationsSetWorkspaceId(report, workspace); + report.generation = health_report.generation; + report.schema_version = health_report.schema_version; + return OK; +} + +// ── Backup and restore ────────────────────────────────────────── + +pub export fn cbm_zova_database_backup(source_path: ?Str, destination_path: ?Str, out: ?*Report) Code { + _ = operationsReport(out, INVALID, "backup", "invalid_argument"); + const source = nonEmpty(source_path) orelse return INVALID; + const destination = nonEmpty(destination_path) orelse return INVALID; + const report = out orelse return INVALID; + if (std.mem.eql(u8, span(source), span(destination))) return INVALID; + if (!with_zova) return INCOMPATIBLE; + if (!operationsPathIsRegularNoSymlink(source) or operationsLstat(destination) != null) return INVALID; + + const started = operationsNow(); + const source_db = operationsOpenReadOnly(source) orelse return VERIFY_FAILED; + const backup_ok = c.zova_database_backup(&c.zova_database_backup_request{ + .db = source_db, + .destination_path = destination, + .flags = 0, + }) == c.ZOVA_OK; + _ = c.zova_database_close(source_db); + var schema_version: c_int = 0; + const destination_size = if (backup_ok and operationsVerifyDatabase(destination, &schema_version)) + operationsFileSize(destination) + else + null; + const database_size = destination_size orelse { + _ = c.cbm_unlink(destination); + return operationsReport(report, VERIFY_FAILED, "backup", "verification_failed"); + }; + const finished = operationsNow(); + _ = operationsReport(report, OK, "backup", "ok"); + report.schema_version = schema_version; + report.database_bytes = operationsBytes(database_size); + report.elapsed_ms = operationsElapsedMs(started, finished); + var wal_buf: [PATH_CAP]u8 = undefined; + if (operationsPath(&wal_buf, "{s}-wal", .{span(source)})) |wal_path| { + if (operationsFileSize(wal_path)) |wal_size| report.wal_bytes = operationsBytes(wal_size); + } + return OK; +} + +fn databaseRestoreBody(live_path: ?Str, backup_path: ?Str, confirm_replace: bool, out: ?*Report) Code { + _ = operationsReport(out, INVALID, "restore", "invalid_argument"); + const live = nonEmpty(live_path) orelse return INVALID; + const backup = nonEmpty(backup_path) orelse return INVALID; + const report = out orelse return INVALID; + if (!confirm_replace) + return operationsReport(report, CONFIRMATION_REQUIRED, "restore", "confirmation_required"); + if (!with_zova) return INCOMPATIBLE; + + var temporary_buf: [PATH_CAP]u8 = undefined; + var recovery_buf: [PATH_CAP]u8 = undefined; + var parent_buf: [PATH_CAP]u8 = undefined; + const stem = operationsZovaStem(span(live)) orelse return INVALID; + const temporary = operationsPath(&temporary_buf, "{s}.restore.tmp.zova", .{stem}) orelse return INVALID; + const recovery = operationsPath(&recovery_buf, "{s}.restore.recovery.zova", .{stem}) orelse return INVALID; + const parent = operationsParentDirectory(span(live), &parent_buf) orelse return INVALID; + if (std.mem.eql(u8, span(temporary), span(live)) or std.mem.eql(u8, span(temporary), span(backup))) + return INVALID; + if (!operationsPathIsRegularNoSymlink(backup)) return INVALID; + const preflight_live_state = operationsRestorePathState(live); + if (preflight_live_state == .other or + (preflight_live_state == .regular and operationsSameFile(live, backup))) + return INVALID; + var guard = std.mem.zeroes(WriterGuard); + if (c.cbm_zova_writer_guard_acquire(live, &guard) != 0) + return operationsReport(report, BUSY, "restore", "writer_busy"); + defer c.cbm_zova_writer_guard_release(&guard); + const started = operationsNow(); + const files = ReplacementFiles.read(live, temporary, recovery); + if (!files.resumable() or + (files.temporary == .regular and operationsSameFile(temporary, backup)) or + (files.recovery == .regular and operationsSameFile(recovery, backup)) or + !operationsVerifyDatabase(backup, null)) + return operationsReport(report, VERIFY_FAILED, "restore", "invalid_recovery_state"); + + if (files.temporary == .absent and files.recovery == .absent) { + var message = std.mem.zeroes(c.zova_message); + const restored = c.zova_database_restore(&c.zova_database_restore_request{ + .source_path = backup, + .destination_path = temporary, + .flags = 0, + .out_error_message = &message, + }) == c.ZOVA_OK; + c.zova_message_free(&message); + if (!restored) return operationsReport(report, VERIFY_FAILED, "restore", "replacement_failed"); + if (operationsReplacementFault("restore", "restore_after_temp_creation", report)) + return VERIFY_FAILED; + } + const replacement = ReplacementOptions{ + .operation = "restore", + .after_temp_verification = "restore_after_temp_verification", + .after_live_to_recovery = "restore_after_live_to_recovery", + .after_temp_to_live = "restore_after_temp_to_live", + .before_recovery_cleanup = "restore_before_recovery_cleanup", + // A hot -wal left by a crashed writer would replay onto the restored file. + .remove_sidecars = true, + .require_source_verified = false, + }; + var schema_version: c_int = 0; + switch (operationsReplaceVerifiedDatabase(live, temporary, recovery, parent, &replacement, &schema_version, report)) { + .interrupted => return VERIFY_FAILED, + .failed => return operationsReport(report, VERIFY_FAILED, "restore", "replacement_failed"), + .done => {}, + } + + const finished = operationsNow(); + _ = operationsReport(report, OK, "restore", "ok"); + report.schema_version = schema_version; + if (operationsFileSize(live)) |live_size| report.database_bytes = operationsBytes(live_size); + report.elapsed_ms = operationsElapsedMs(started, finished); + return OK; +} + +// ── Database archives ─────────────────────────────────────────── + +pub export fn cbm_zova_database_export(source_path: ?Str, archive_directory: ?Str, out: ?*Report) Code { + _ = operationsReport(out, INVALID, "export_database", "invalid_argument"); + const source = nonEmpty(source_path) orelse return INVALID; + const archive = nonEmpty(archive_directory) orelse return INVALID; + const report = out orelse return INVALID; + if (!with_zova) return INCOMPATIBLE; + if (!operationsPathIsRegularNoSymlink(source)) return INVALID; + + var partial_buf: [PATH_CAP]u8 = undefined; + var data_buf: [PATH_CAP]u8 = undefined; + var manifest_buf: [PATH_CAP]u8 = undefined; + var parent_buf: [PATH_CAP]u8 = undefined; + const partial = operationsPath(&partial_buf, "{s}.partial", .{span(archive)}) orelse return INVALID; + const data = operationsPath(&data_buf, "{s}/data.zova", .{span(partial)}) orelse return INVALID; + const manifest_path = operationsPath(&manifest_buf, "{s}/manifest.json", .{span(partial)}) orelse return INVALID; + const parent = operationsParentDirectory(span(archive), &parent_buf) orelse return INVALID; + if (operationsLstat(archive) != null) return INVALID; + if (operationsLstat(partial) != null) + return operationsReport(report, BUSY, "export_database", "partial_exists"); + if (!operationsMakePrivateDirectory(partial)) return INVALID; + + const started = operationsNow(); + var backup = std.mem.zeroes(Report); + var manifest = ArchiveManifest{ .archive_version = ARCHIVE_VERSION, .schema_version = SCHEMA_VERSION }; + const exported = cbm_zova_database_backup(source, data, &backup) == OK and + operationsFileSha256(data, &manifest.database_sha256, &manifest.database_bytes) and + operationsWriteArchiveManifest(data, manifest_path, &manifest) and + operationsRemoveClosedDatabaseSidecars(data) and + operationsVerifyArchive(partial, null, null) == .valid and + operationsRemoveClosedDatabaseSidecars(data) and + operationsArchiveHasExactMembers(partial) and + operationsSyncDirectory(partial) and + c.rename(partial, archive) == 0 and + operationsSyncDirectory(parent); + if (!exported) { + operationsRemovePartialArchive(partial); + return operationsReport(report, VERIFY_FAILED, "export_database", "archive_verification_failed"); + } + const finished = operationsNow(); + _ = operationsReport(report, OK, "export_database", "ok"); + report.archive_version = manifest.archive_version; + report.schema_version = manifest.schema_version; + report.database_bytes = manifest.database_bytes; + report.elapsed_ms = operationsElapsedMs(started, finished); + return OK; +} + +pub export fn cbm_zova_database_import(live_path: ?Str, archive_directory: ?Str, confirm_replace: bool, out: ?*Report) Code { + _ = operationsReport(out, INVALID, "import_database", "invalid_argument"); + const live = nonEmpty(live_path) orelse return INVALID; + const archive = nonEmpty(archive_directory) orelse return INVALID; + const report = out orelse return INVALID; + if (!confirm_replace) + return operationsReport(report, CONFIRMATION_REQUIRED, "import_database", "confirmation_required"); + if (!with_zova) return INCOMPATIBLE; + + const value = operationsLstat(archive) orelse return INVALID; + if (!operationsIsDirectory(&value)) return INVALID; + var manifest = ArchiveManifest{}; + switch (operationsVerifyArchive(archive, live, &manifest)) { + .valid => {}, + .incompatible => return operationsReport(report, INCOMPATIBLE, "import_database", "archive_version_incompatible"), + .path_alias => return INVALID, + .malformed => return operationsReport(report, VERIFY_FAILED, "import_database", "archive_verification_failed"), + } + var data_buf: [PATH_CAP]u8 = undefined; + const data = operationsPath(&data_buf, "{s}/data.zova", .{span(archive)}) orelse return INVALID; + const code = cbm_zova_database_restore(live, data, true, report); + if (code == OK) { + _ = interop.bprint(&report.operation, "{s}", .{"import_database"}); + report.archive_version = manifest.archive_version; + } + return code; +} + +// ── Workspace archives ────────────────────────────────────────── + +pub export fn cbm_zova_workspace_export(source_path: ?Str, workspace_id: ?Str, archive_directory: ?Str, out: ?*Report) Code { + const operation = "export_workspace"; + _ = operationsReport(out, INVALID, operation, "invalid_argument"); + const source = nonEmpty(source_path) orelse return INVALID; + const archive = nonEmpty(archive_directory) orelse return INVALID; + const report = out orelse return INVALID; + if (c.cbm_zova_workspace_id_validate(workspace_id) != 0) return INVALID; + const workspace = workspace_id orelse return INVALID; + if (!with_zova) return INCOMPATIBLE; + if (!operationsPathIsRegularNoSymlink(source)) return INVALID; + + var snapshot = std.mem.zeroes(Snapshot); + if (c.cbm_zova_repository_export_snapshot(source, workspace, &snapshot) != 0) + return operationsReport(report, INVALID, operation, "workspace_not_ready"); + defer c.cbm_zova_workspace_snapshot_free(&snapshot); + var partial_buf: [PATH_CAP]u8 = undefined; + var data_buf: [PATH_CAP]u8 = undefined; + var manifest_buf: [PATH_CAP]u8 = undefined; + var parent_buf: [PATH_CAP]u8 = undefined; + const partial = operationsPath(&partial_buf, "{s}.partial", .{span(archive)}) orelse return INVALID; + const data = operationsPath(&data_buf, "{s}/data.zova", .{span(partial)}) orelse return INVALID; + const manifest_path = operationsPath(&manifest_buf, "{s}/manifest.json", .{span(partial)}) orelse return INVALID; + const parent = operationsParentDirectory(span(archive), &parent_buf) orelse return INVALID; + if (operationsLstat(archive) != null) return INVALID; + if (operationsLstat(partial) != null) return operationsReport(report, BUSY, operation, "partial_exists"); + if (!operationsMakePrivateDirectory(partial)) return INVALID; + + const started = operationsNow(); + const input = operationsSnapshotInput(&snapshot); + var published = std.mem.zeroes(GenerationResult); + var manifest = ArchiveManifest{ .archive_version = ARCHIVE_VERSION, .schema_version = SCHEMA_VERSION }; + var failure_phase: []const u8 = "publish_failed"; + var ok = c.cbm_zova_user_database_publish_workspace_at_generation(data, &input, snapshot.generation, &published) == 0; + if (ok) { + failure_phase = "published_inventory_mismatch"; + ok = operationsGenerationMatches(&published, &snapshot.integrity); + } + if (ok) { + failure_phase = "sidecar_cleanup_failed"; + ok = operationsRemoveClosedDatabaseSidecars(data); + } + if (ok) { + failure_phase = "writer_lock_cleanup_failed"; + ok = operationsRemoveClosedWriterLock(data); + } + if (ok) { + failure_phase = "published_snapshot_verification_failed"; + var verified = std.mem.zeroes(Snapshot); + ok = c.cbm_zova_repository_export_snapshot(data, workspace, &verified) == 0; + c.cbm_zova_workspace_snapshot_free(&verified); + } + if (ok) { + failure_phase = "database_digest_failed"; + ok = operationsFileSha256(data, &manifest.database_sha256, &manifest.database_bytes); + } + if (ok) { + failure_phase = "manifest_write_failed"; + ok = operationsWriteWorkspaceArchiveManifest(manifest_path, &manifest, &snapshot); + } + if (ok) { + failure_phase = "manifest_sidecar_cleanup_failed"; + ok = operationsRemoveClosedDatabaseSidecars(data); + } + if (ok) { + failure_phase = "archive_verification_failed"; + ok = operationsVerifyWorkspaceArchive(partial, null, null) == .valid; + } + if (ok) { + failure_phase = "verification_sidecar_cleanup_failed"; + ok = operationsRemoveClosedDatabaseSidecars(data); + } + if (ok) { + failure_phase = "archive_members_failed"; + ok = operationsArchiveHasExactMembers(partial); + } + if (ok) { + failure_phase = "partial_sync_failed"; + ok = operationsSyncDirectory(partial); + } + if (ok) { + failure_phase = "archive_rename_failed"; + ok = c.rename(partial, archive) == 0; + } + if (ok) { + failure_phase = "parent_sync_failed"; + ok = operationsSyncDirectory(parent); + } + if (!ok) { + operationsRemovePartialArchive(partial); + return operationsReport(report, VERIFY_FAILED, operation, failure_phase); + } + const finished = operationsNow(); + _ = operationsReport(report, OK, operation, "ok"); + operationsSetWorkspaceId(report, workspace); + report.archive_version = manifest.archive_version; + report.schema_version = manifest.schema_version; + report.generation = snapshot.generation; + report.database_bytes = manifest.database_bytes; + report.elapsed_ms = operationsElapsedMs(started, finished); + return OK; +} + +pub export fn cbm_zova_workspace_import(target_path: ?Str, archive_directory: ?Str, replace: bool, out: ?*Report) Code { + const operation = "import_workspace"; + _ = operationsReport(out, INVALID, operation, "invalid_argument"); + const target = nonEmpty(target_path) orelse return INVALID; + const archive = nonEmpty(archive_directory) orelse return INVALID; + const report = out orelse return INVALID; + if (!with_zova) return INCOMPATIBLE; + + const target_state = operationsRestorePathState(target); + if (target_state == .other) return INVALID; + var manifest = ArchiveManifest{}; + switch (operationsVerifyWorkspaceArchive(archive, target, &manifest)) { + .valid => {}, + .incompatible => return operationsReport(report, INCOMPATIBLE, operation, "archive_version_incompatible"), + .path_alias => return INVALID, + .malformed => return operationsReport(report, VERIFY_FAILED, operation, "archive_verification_failed"), + } + var data_buf: [PATH_CAP]u8 = undefined; + const data = operationsPath(&data_buf, "{s}/data.zova", .{span(archive)}) orelse return INVALID; + var workspace_buf: [ID_MAX]u8 = @splat(0); + if (!operationsSingleReadyWorkspace(data, &workspace_buf)) { + _ = operationsCleanupVerifiedArchiveReads(archive); + return operationsReport(report, VERIFY_FAILED, operation, "workspace_inventory_invalid"); + } + const workspace: Str = @ptrCast(&workspace_buf); + var snapshot = std.mem.zeroes(Snapshot); + if (c.cbm_zova_repository_export_snapshot(data, workspace, &snapshot) != 0) { + _ = operationsCleanupVerifiedArchiveReads(archive); + return operationsReport(report, VERIFY_FAILED, operation, "workspace_snapshot_invalid"); + } + defer c.cbm_zova_workspace_snapshot_free(&snapshot); + if (!operationsCleanupVerifiedArchiveReads(archive)) + return operationsReport(report, VERIFY_FAILED, operation, "archive_read_cleanup_failed"); + // The import publishes this workspace, so it holds what an index run of it + // holds: the barrier shared, then the workspace lease. It does not wait. + var barrier = std.mem.zeroes(CoordHold); + var lease = std.mem.zeroes(CoordHold); + defer c.cbm_zova_coord_release(&barrier); + defer c.cbm_zova_coord_release(&lease); + var coord = c.cbm_zova_prepare_barrier_acquire(target, false, 0, &barrier); + var busy_reason: []const u8 = "database_replacement_in_progress"; + if (coord == c.CBM_ZOVA_COORD_OK) { + coord = c.cbm_zova_workspace_lease_acquire(target, workspace, 0, &lease); + busy_reason = "workspace_busy"; + } + if (coord != c.CBM_ZOVA_COORD_OK) return operationsCoordRefusal(report, operation, coord, busy_reason); + + const started = operationsNow(); + var schema_version: c_int = 0; + if ((target_state == .absent and c.cbm_zova_user_database_init(target) != 0) or + !operationsVerifyDatabase(target, &schema_version) or schema_version != SCHEMA_VERSION) + return operationsReport(report, VERIFY_FAILED, operation, "target_verification_failed"); + const existing_present = existing: { + var collision = std.mem.zeroes(Snapshot); + defer c.cbm_zova_workspace_snapshot_free(&collision); + if (c.cbm_zova_repository_export_snapshot(target, workspace, &collision) != 0) break :existing false; + if (!bufEql(&collision.workspace_id, &snapshot.workspace_id) or + !std.mem.eql(u8, span(collision.root_path), span(snapshot.root_path))) + return operationsReport(report, INVALID, operation, "identity_mismatch"); + if (!replace) return operationsReport(report, INVALID, operation, "workspace_exists"); + if (snapshot.generation < collision.generation) + return operationsReport(report, INCOMPATIBLE, operation, "archive_generation_older"); + if (snapshot.generation != collision.generation) break :existing true; + if (!operationsGenerationMatches(&collision.integrity, &snapshot.integrity)) + return operationsReport(report, VERIFY_FAILED, operation, "same_generation_digest_mismatch"); + const finished = operationsNow(); + _ = operationsReport(report, NOOP, operation, "already_current"); + operationsSetWorkspaceId(report, workspace); + report.schema_version = schema_version; + report.archive_version = manifest.archive_version; + report.generation = snapshot.generation; + if (operationsFileSize(target)) |target_size| report.database_bytes = operationsBytes(target_size); + report.elapsed_ms = operationsElapsedMs(started, finished); + return NOOP; + }; + var workspaces_before: i64 = 0; + var workspaces_after: i64 = 0; + if (!operationsWorkspaceCount(target, &workspaces_before)) + return operationsReport(report, VERIFY_FAILED, operation, "target_inventory_failed"); + const input = operationsSnapshotInput(&snapshot); + var published = std.mem.zeroes(GenerationResult); + var imported = std.mem.zeroes(Snapshot); + const verified = c.cbm_zova_user_database_publish_workspace_at_generation(target, &input, snapshot.generation, &published) == 0 and + operationsGenerationMatches(&published, &snapshot.integrity) and + c.cbm_zova_repository_export_snapshot(target, workspace, &imported) == 0 and + operationsGenerationMatches(&imported.integrity, &snapshot.integrity) and + operationsWorkspaceCount(target, &workspaces_after) and + workspaces_after == workspaces_before + @intFromBool(!existing_present); + c.cbm_zova_workspace_snapshot_free(&imported); + if (!verified) return operationsReport(report, VERIFY_FAILED, operation, "published_verification_failed"); + const finished = operationsNow(); + _ = operationsReport(report, OK, operation, "ok"); + operationsSetWorkspaceId(report, workspace); + report.schema_version = schema_version; + report.archive_version = manifest.archive_version; + report.generation = snapshot.generation; + if (operationsFileSize(target)) |target_size| report.database_bytes = operationsBytes(target_size); + report.elapsed_ms = operationsElapsedMs(started, finished); + return OK; +} + +// ── Repack ────────────────────────────────────────────────────── + +fn repackCode(out: *RepackReport, code: Code) Code { + out.code = code; + return code; +} + +/// Publishes every workspace of a schema-5 snapshot into `temporary` and +/// checks each against the digest of its input. +fn operationsRepackPublish(snapshot: *const c.cbm_zova_v5_snapshot_t, count: c_int, temporary: Str) bool { + var i: c_int = 0; + while (i < count) : (i += 1) { + var generation: i64 = 0; + const input = c.cbm_zova_v5_snapshot_input_at(snapshot, i, &generation); + var expected = std.mem.zeroes(GenerationResult); + var published = std.mem.zeroes(GenerationResult); + var workspace_id: [ID_MAX]u8 = undefined; + if (input == null or generation <= 0 or + c.cbm_zova_workspace_id_for_root(input.*.root_path, &workspace_id, workspace_id.len) != 0 or + c.cbm_zova_workspace_generation_digest_input(&workspace_id, input, &expected) != 0) + return false; + expected.generation = generation; + _ = interop.bprint(&expected.workspace_id, "{s}", .{sliceTo(&workspace_id, 0)}); + if (c.cbm_zova_user_database_publish_workspace_at_generation(temporary, input, generation, &published) != 0 or + !operationsGenerationMatches(&published, &expected)) + return false; + interop.logInfo("zova.repack", .{ "phase", "workspace_published" }); + } + return true; +} + +/// The repacked file is a sound current-schema database holding `count` +/// workspaces and none of the schema-5 tables. +fn operationsRepackTargetVerified(temporary: Str, count: c_int) bool { + var schema: c_int = 0; + if (!operationsVerifyDatabase(temporary, &schema)) return false; + interop.logInfo("zova.repack", .{ "phase", "target_verified" }); + const target_db = operationsOpen(temporary, 0, 5000, null) orelse return false; + var target_workspaces: i64 = -1; + var forbidden: i64 = -1; + const read = operationsCheckpointTruncate(target_db) and + operationsQueryI64(target_db, workspace_count_sql, &target_workspaces) and + operationsQueryI64( + target_db, + "SELECT count(*) FROM sqlite_master WHERE name IN ('cbm_zova_trace_nodes_v1'," ++ + "'cbm_zova_edge_metadata_v1','cbm_fts_rowmap_v1','cbm_node_vectors_compat_v1'," ++ + "'cbm_token_vectors_compat_v1') OR name GLOB 'cbm_fts_w1_*'", + &forbidden, + ); + _ = c.zova_database_close(target_db); + return read and schema == SCHEMA_VERSION and target_workspaces == count and forbidden == 0; +} + +fn databaseRepackBody(request: ?*const RepackRequest, out: ?*RepackReport) Code { + const report = out orelse return INVALID; + report.* = std.mem.zeroes(RepackReport); + const live = nonEmpty(if (request) |r| @as(?Str, @ptrCast(r.live_path)) else null) orelse + return repackCode(report, INVALID); + if (!with_zova) return repackCode(report, INCOMPATIBLE); + + const keep_recovery = request.?.keep_recovery; + var temporary_buf: [PATH_CAP]u8 = undefined; + var recovery_buf: [PATH_CAP]u8 = undefined; + var parent_buf: [PATH_CAP]u8 = undefined; + const stem = operationsZovaStem(span(live)) orelse return repackCode(report, INVALID); + const temporary = operationsPath(&temporary_buf, "{s}.repack.tmp.zova", .{stem}) orelse + return repackCode(report, INVALID); + const recovery = operationsPath(&recovery_buf, "{s}.repack.recovery.zova", .{stem}) orelse + return repackCode(report, INVALID); + const parent = operationsParentDirectory(span(live), &parent_buf) orelse + return repackCode(report, INVALID); + _ = interop.bprint(&report.recovery_path, "{s}", .{span(recovery)}); + var guard = std.mem.zeroes(WriterGuard); + if (c.cbm_zova_writer_guard_try_acquire(live, &guard) != 0) return repackCode(report, BUSY); + defer c.cbm_zova_writer_guard_release(&guard); + var files = ReplacementFiles.read(live, temporary, recovery); + if (files.anyOther()) return repackCode(report, VERIFY_FAILED); + + var ignored_bytes: u64 = 0; + if (files.are(.regular, .absent, .absent)) { + if (operationsVerifyDatabase(live, null)) { + report.source_cbm_schema = SCHEMA_VERSION; + report.target_cbm_schema = SCHEMA_VERSION; + return repackCode(report, NOOP); + } + if (!operationsRawSqliteCheckpointTruncate(live)) return repackCode(report, BUSY); + interop.logInfo("zova.repack", .{ "phase", "source_checkpointed" }); + var opened: ?*c.cbm_zova_v5_snapshot_t = null; + if (c.cbm_zova_v5_snapshot_open(live, &opened) != 0 or opened == null) + return repackCode(report, INCOMPATIBLE); + const snapshot = opened.?; + const count = c.cbm_zova_v5_snapshot_workspace_count(snapshot); + interop.logInfo("zova.repack", .{ "phase", "source_snapshot", "workspaces", @as(Str, if (count == 2) "2" else "other") }); + report.source_cbm_schema = 5; + report.target_cbm_schema = SCHEMA_VERSION; + report.workspace_count = count; + if (count < 1 or !operationsFileSha256(live, &report.source_sha256, &ignored_bytes)) { + c.cbm_zova_v5_snapshot_close(snapshot); + return repackCode(report, VERIFY_FAILED); + } + _ = operationsRemoveClosedDatabaseSidecars(temporary); + _ = c.cbm_unlink(temporary); + // The gate has one process-wide mutex, so publishing the isolated + // sibling temp while holding the live guard would self-deadlock. + // Revalidate the source digest after reacquiring before replacement. + c.cbm_zova_writer_guard_release(&guard); + var ok = operationsRepackPublish(snapshot, count, temporary); + c.cbm_zova_v5_snapshot_close(snapshot); + if (ok and c.cbm_zova_writer_guard_try_acquire(live, &guard) != 0) return repackCode(report, BUSY); + if (ok) { + var current_source: [HEX_LEN + 1]u8 = undefined; + ok = operationsFileSha256(live, ¤t_source, &ignored_bytes) and + bufEql(¤t_source, &report.source_sha256); + } + ok = ok and operationsRepackTargetVerified(temporary, count) and + operationsFileSha256(temporary, &report.target_sha256, &ignored_bytes) and + operationsSyncFile(temporary) and operationsSyncFile(live) and + operationsSyncDirectory(parent); + if (!ok) return repackCode(report, VERIFY_FAILED); + files.temporary = .regular; + if (operationsReplacementFault("repack", "repack_after_temp_creation", null)) + return repackCode(report, VERIFY_FAILED); + } + + if (files.are(.regular, .absent, .regular) and operationsVerifyDatabase(live, null)) { + report.source_cbm_schema = 5; + report.target_cbm_schema = SCHEMA_VERSION; + _ = operationsFileSha256(recovery, &report.source_sha256, &ignored_bytes); + _ = operationsFileSha256(live, &report.target_sha256, &ignored_bytes); + if (!keep_recovery and + (!operationsRemoveClosedDatabaseSidecars(recovery) or + c.cbm_unlink(recovery) != 0 or !operationsSyncDirectory(parent))) + return repackCode(report, VERIFY_FAILED); + return repackCode(report, NOOP); + } + const replacement = ReplacementOptions{ + .operation = "repack", + .after_temp_verification = "repack_after_temp_verification", + .after_live_to_recovery = "repack_after_live_to_recovery", + .after_temp_to_live = "repack_after_temp_to_live", + .before_recovery_cleanup = "repack_before_recovery_cleanup", + .remove_sidecars = true, + .require_source_verified = false, + .keep_recovery = keep_recovery, + }; + if (report.source_sha256[0] == 0) { + const source_path = if (files.recovery == .regular) recovery else live; + if (!operationsFileSha256(source_path, &report.source_sha256, &ignored_bytes)) + return repackCode(report, VERIFY_FAILED); + } + var target_schema: c_int = 0; + const replaced = operationsReplaceVerifiedDatabase(live, temporary, recovery, parent, &replacement, &target_schema, null); + interop.logInfo("zova.repack", .{ "phase", "replacement_returned" }); + if (replaced != .done) return repackCode(report, VERIFY_FAILED); + report.source_cbm_schema = 5; + report.target_cbm_schema = target_schema; + var count: i64 = 0; + if (!operationsWorkspaceCount(live, &count) or count > std.math.maxInt(c_int)) + return repackCode(report, VERIFY_FAILED); + report.workspace_count = @truncate(count); + if (keep_recovery) _ = operationsFileSha256(recovery, &report.source_sha256, &ignored_bytes); + if (!operationsFileSha256(live, &report.target_sha256, &ignored_bytes)) + return repackCode(report, VERIFY_FAILED); + return repackCode(report, OK); +} + +// ── Coordination (ADR-0001) ───────────────────────────────────── + +/// Map a refused lease or barrier acquisition onto the operation report. +fn operationsCoordRefusal(out: ?*Report, operation: []const u8, status: CoordStatus, busy_reason: []const u8) Code { + if (status == c.CBM_ZOVA_COORD_ERROR) + return operationsReport(out, VERIFY_FAILED, operation, "coordination_unavailable"); + return operationsReport(out, BUSY, operation, if (status == c.CBM_ZOVA_COORD_CANCELLED) "cancelled" else busy_reason); +} + +/// Deleting a workspace mutates it, so it takes the workspace lease. It does +/// not wait: callers that should wait (MCP delete_project) retry on BUSY. +pub export fn cbm_zova_workspace_delete(path: ?Str, workspace_id: ?Str, confirmation: ?Str, out: ?*Report) Code { + if (!with_zova or nonEmpty(path) == null or c.cbm_zova_workspace_id_validate(workspace_id) != 0) + return workspaceDeleteBody(path, workspace_id, confirmation, out); + var lease = std.mem.zeroes(CoordHold); + const status = c.cbm_zova_workspace_lease_acquire(path, workspace_id, 0, &lease); + if (status != c.CBM_ZOVA_COORD_OK) + return operationsCoordRefusal(out, "delete_workspace", status, "workspace_busy"); + defer c.cbm_zova_coord_release(&lease); + return workspaceDeleteBody(path, workspace_id, confirmation, out); +} + +/// Quarantine replaces the database under every workspace, so like restore it +/// holds the prepare barrier exclusive (index runs elsewhere hold it shared), +/// and it re-checks health under that hold before moving the file aside. +fn operationsRecoverQuarantine(path: Str, workspace_id: Str, out: *Report) Code { + const operation = "recover_workspace"; + var barrier = std.mem.zeroes(CoordHold); + const status = c.cbm_zova_prepare_barrier_acquire(path, true, 0, &barrier); + if (status != c.CBM_ZOVA_COORD_OK) + return operationsCoordRefusal(out, operation, status, "indexing_in_progress"); + defer c.cbm_zova_coord_release(&barrier); + var health: HealthClass = c.CBM_ZOVA_HEALTH_OK; + var health_report = std.mem.zeroes(Report); + const health_code = cbm_zova_database_health(path, workspace_id, &health, &health_report); + if (health_code != WHOLE_FILE_RECOVERY_REQUIRED or health != c.CBM_ZOVA_HEALTH_WHOLE_FILE_RECOVERY) { + // The file changed state while no hold was taken; report what it is now. + if (health_code == OK) return operationsReport(out, NOOP, operation, "workspace_healthy"); + return operationsReport(out, health_code, operation, sliceTo(&health_report.reason, 0)); + } + if (!operationsQuarantineDatabase(path)) + return operationsReport(out, VERIFY_FAILED, operation, "quarantine_failed"); + _ = operationsReport(out, WHOLE_FILE_RECOVERY_REQUIRED, operation, "quarantined_no_verified_backup"); + operationsSetWorkspaceId(out, workspace_id); + return WHOLE_FILE_RECOVERY_REQUIRED; +} + +/// Recovery rebuilds the workspace through a pipeline, so it holds what every +/// index run holds: the barrier shared, then the workspace lease. +pub export fn cbm_zova_workspace_recover(path: ?Str, workspace_id: ?Str, repo_path: ?Str, out: ?*Report) Code { + if (!with_zova or nonEmpty(path) == null or c.cbm_zova_workspace_id_validate(workspace_id) != 0) + return workspaceRecoverBody(path, workspace_id, repo_path, out); + const code = held: { + var barrier = std.mem.zeroes(CoordHold); + var lease = std.mem.zeroes(CoordHold); + var status = c.cbm_zova_prepare_barrier_acquire(path, false, 0, &barrier); + if (status != c.CBM_ZOVA_COORD_OK) + return operationsCoordRefusal(out, "recover_workspace", status, "database_replacement_in_progress"); + defer c.cbm_zova_coord_release(&barrier); + status = c.cbm_zova_workspace_lease_acquire(path, workspace_id, 0, &lease); + if (status != c.CBM_ZOVA_COORD_OK) + return operationsCoordRefusal(out, "recover_workspace", status, "workspace_busy"); + defer c.cbm_zova_coord_release(&lease); + break :held workspaceRecoverBody(path, workspace_id, repo_path, out); + }; + const report = out orelse return code; + if (code != WHOLE_FILE_RECOVERY_REQUIRED or !bufEql(&report.reason, "whole_file_recovery_required")) + return code; + return operationsRecoverQuarantine(path.?, workspace_id.?, report); +} + +/// Restore replaces the database file, so it waits (bounded) for index runs +/// that prepared against the current file to finish, and keeps new ones from +/// starting until the replacement is done. +pub export fn cbm_zova_database_restore(live_path: ?Str, backup_path: ?Str, confirm_replace: bool, out: ?*Report) Code { + if (!with_zova or nonEmpty(live_path) == null or nonEmpty(backup_path) == null or !confirm_replace) + return databaseRestoreBody(live_path, backup_path, confirm_replace, out); + var barrier = std.mem.zeroes(CoordHold); + const status = c.cbm_zova_prepare_barrier_acquire(live_path, true, c.cbm_zova_coord_default_wait_ms(), &barrier); + if (status != c.CBM_ZOVA_COORD_OK) + return operationsCoordRefusal(out, "restore", status, "indexing_in_progress"); + defer c.cbm_zova_coord_release(&barrier); + return databaseRestoreBody(live_path, backup_path, confirm_replace, out); +} + +/// Repack replaces the database file; like its writer-gate acquisition it +/// does not wait. +pub export fn cbm_zova_database_repack(request: ?*const RepackRequest, out: ?*RepackReport) Code { + const report = out orelse return databaseRepackBody(request, out); + const live = nonEmpty(if (request) |r| @as(?Str, @ptrCast(r.live_path)) else null); + if (!with_zova or live == null) return databaseRepackBody(request, out); + var barrier = std.mem.zeroes(CoordHold); + const status = c.cbm_zova_prepare_barrier_acquire(live, true, 0, &barrier); + if (status != c.CBM_ZOVA_COORD_OK) { + report.* = std.mem.zeroes(RepackReport); + return repackCode(report, if (status == c.CBM_ZOVA_COORD_ERROR) VERIFY_FAILED else BUSY); + } + defer c.cbm_zova_coord_release(&barrier); + return databaseRepackBody(request, out); +} diff --git a/tests/test_github_workflows.sh b/tests/test_github_workflows.sh index 93eca989..3b3e695a 100644 --- a/tests/test_github_workflows.sh +++ b/tests/test_github_workflows.sh @@ -139,7 +139,7 @@ grep -q '#define cbm_lstat' "$ROOT/src/foundation/compat.h" grep -q '#define cbm_mkdir_mode' "$ROOT/src/foundation/compat.h" for path in \ "$ROOT/src/store/store.zig" \ - "$ROOT/src/zova/cbm_zova_operations.c" \ + "$ROOT/src/zova/cbm_zova_operations.zig" \ "$ROOT/src/cli/cli.zig" \ "$ROOT/src/cli/commands.zig"; do if rg -n '\blstat\(' "$path"; then @@ -147,7 +147,7 @@ for path in \ exit 1 fi done -if rg -n '\bmkdir\([^,]+,' "$ROOT/src/zova/cbm_zova_operations.c"; then +if rg -n '\bmkdir\([^,]+,' "$ROOT/src/zova/cbm_zova_operations.zig"; then echo "error: Zova operations bypasses the Windows-compatible mkdir wrapper" >&2 exit 1 fi From 6584d49524698e25bbd70cd0553568eb7b9a145b Mon Sep 17 00:00:00 2001 From: ata-sesli Date: Tue, 10 Nov 2026 22:30:49 +0300 Subject: [PATCH 7/9] refactor(zova): port cbm_zova_repository from C to Zig cbm_zova_repository.c (3,553 lines) becomes cbm_zova_repository.zig (3,619 lines). The header stays the ABI; every export keeps its name, signature and no-Zova stub behaviour. All SQL text is unchanged. Form differences: - the seven degree arrays of a search page are one allocation block - node and token vector reads share snapshotVectorBytes/snapshotVectorCount - the unused out_stable_ids parameter of the snapshot node reader is gone - snapshot metrics are not updated on failure paths (the snapshot is discarded there) Behavior differences: - Zova SDK results are always released (defer). C skipped the release when a keyed read returned a different count and when the payload get-many call failed. - a partial FTS catalog row is freed when validation fails - search_fts frees the key array on step and realloc errors - search frees the page, and the rows moved into it, on the data-error path - a statement returned together with a failure status is finalized - a failed open closes a returned handle - cbm_zova_workspace_snapshot_free does not walk an array that failed to allocate while its count was already set (OOM only) - the vector byte buffer is freed when the id or project copy fails (OOM only) Build wiring: Makefile.cbm ZOVA_SRCS/CBM_ZIG_SRCS, build.zig, checkExports in src/cbm_zig.zig. Signed-off-by: ata-sesli --- Makefile.cbm | 4 +- build.zig | 1 - src/cbm_zig.zig | 1 + src/zova/cbm_zova_repository.c | 3553 ----------------------------- src/zova/cbm_zova_repository.zig | 3619 ++++++++++++++++++++++++++++++ 5 files changed, 3622 insertions(+), 3556 deletions(-) delete mode 100644 src/zova/cbm_zova_repository.c create mode 100644 src/zova/cbm_zova_repository.zig diff --git a/Makefile.cbm b/Makefile.cbm index fdb2c384..4c01176c 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -152,8 +152,7 @@ PREPROCESSOR_SRC = $(CBM_DIR)/preprocessor.cpp # Optional Zova storage adapter. Compiles to no-op stubs unless # CBM_WITH_ZOVA=1 is set for the build. ZOVA_SRCS = \ - src/zova/cbm_zova.c \ - src/zova/cbm_zova_repository.c + src/zova/cbm_zova.c # MCP server module: implemented in Zig (src/mcp/*.zig, part of libcbm_zig) MCP_SRCS = @@ -487,6 +486,7 @@ CBM_ZIG_SRCS = $(CBM_ZIG_SRC) src/cbm_zig_c.h src/cli_zig/cbm_cli_zig.zig src/cl src/zova/cbm_zova_delta.zig src/zova/cbm_zova_edge_payload.zig \ src/zova/cbm_zova_operations.zig \ src/zova/cbm_zova_plugin.zig src/zova/cbm_zova_publish_model.zig \ + src/zova/cbm_zova_repository.zig \ src/zova/cbm_zova_route.zig \ src/zova/cbm_zova_v5_snapshot.zig \ src/zova/cbm_zova_writer_gate.zig \ diff --git a/build.zig b/build.zig index 0cf6fede..73aa11d0 100644 --- a/build.zig +++ b/build.zig @@ -537,7 +537,6 @@ const native_sources = [_][]const u8{ "src/foundation/hash_table.c", "src/foundation/c_shim.c", "src/zova/cbm_zova.c", - "src/zova/cbm_zova_repository.c", "src/pipeline/fqn.c", "src/pipeline/path_alias.c", "src/pipeline/laravel_routing.c", diff --git a/src/cbm_zig.zig b/src/cbm_zig.zig index 19ba0a08..606af9cd 100644 --- a/src/cbm_zig.zig +++ b/src/cbm_zig.zig @@ -71,6 +71,7 @@ comptime { checkExports(@import("zova/cbm_zova_operations.zig")); checkExports(@import("zova/cbm_zova_plugin.zig")); checkExports(@import("zova/cbm_zova_publish_model.zig")); + checkExports(@import("zova/cbm_zova_repository.zig")); checkExports(@import("zova/cbm_zova_route.zig")); checkExports(@import("zova/cbm_zova_v5_snapshot.zig")); checkExports(@import("zova/cbm_zova_writer_gate.zig")); diff --git a/src/zova/cbm_zova_repository.c b/src/zova/cbm_zova_repository.c deleted file mode 100644 index 53b42de4..00000000 --- a/src/zova/cbm_zova_repository.c +++ /dev/null @@ -1,3553 +0,0 @@ -#include "zova/cbm_zova_repository.h" - -#include "zova/cbm_zova.h" -#include "zova/cbm_zova_edge_payload.h" -#include "foundation/compat.h" -#include "foundation/constants.h" -#include "foundation/hash_table.h" -#include "foundation/sha256.h" -#include "graph_buffer/graph_buffer.h" -#include "pipeline/pipeline.h" - -#include -#include -#include -#include - -#if CBM_WITH_ZOVA -#include "zova.h" - -typedef struct { - int64_t numeric_id; - size_t order; - int64_t node_key; - char *stable_id; -} repo_numeric_id_entry_t; - -struct cbm_zova_repository { - zova_database *db; - char workspace_id[CBM_ZOVA_WORKSPACE_ID_MAX]; - char project[CBM_SZ_4K]; - int64_t workspace_key; - int64_t generation; - /* Graph nodes sorted by numeric id (the hash of the stable id), built on - * the first id lookup so per-node reads do not rescan the whole graph. */ - repo_numeric_id_entry_t *numeric_ids; - size_t numeric_id_count; - struct repo_string_arena *numeric_id_strings; -}; - -struct cbm_zova_catalog { - zova_database *db; - cbm_zova_catalog_project_t *projects; - int count; - bool transaction_open; - char error[512]; -}; - -enum { - REPO_GRAPH_SCAN_PAGE = 1024, - REPO_EDGE_SCAN_PAGE = 4096, - REPO_GRAPH_NAME_CAP = CBM_ZOVA_WORKSPACE_ID_MAX + 64, - REPO_STRING_ARENA_CHUNK = 64 * 1024, -}; - -typedef struct repo_string_arena_chunk { - struct repo_string_arena_chunk *next; - size_t used; - size_t capacity; - char data[]; -} repo_string_arena_chunk_t; - -typedef struct repo_string_arena { - repo_string_arena_chunk_t *head; - uint64_t chunk_count; - uint64_t bytes_used; -} repo_string_arena_t; - -static void repo_string_arena_free(repo_string_arena_t *arena) { - if (!arena) return; - repo_string_arena_chunk_t *chunk = arena->head; - while (chunk) { - repo_string_arena_chunk_t *next = chunk->next; - free(chunk); - chunk = next; - } - free(arena); -} - -static char *repo_string_arena_dup(repo_string_arena_t *arena, - const uint8_t *text, size_t len) { - if (!arena) return NULL; - size_t needed = len + 1; - repo_string_arena_chunk_t *chunk = arena->head; - if (!chunk || chunk->capacity - chunk->used < needed) { - size_t capacity = needed > REPO_STRING_ARENA_CHUNK - ? needed : REPO_STRING_ARENA_CHUNK; - chunk = malloc(sizeof(*chunk) + capacity); - if (!chunk) return NULL; - *chunk = (repo_string_arena_chunk_t){ - .next = arena->head, .used = 0, .capacity = capacity}; - arena->head = chunk; - arena->chunk_count++; - } - char *copy = chunk->data + chunk->used; - if (len) memcpy(copy, text, len); - copy[len] = '\0'; - chunk->used += needed; - arena->bytes_used += needed; - return copy; -} - -static char *repo_dup(const uint8_t *text, size_t len) { - char *copy = malloc(len + 1); - if (!copy) return NULL; - if (len) memcpy(copy, text, len); - copy[len] = '\0'; - return copy; -} - -static int repo_prepare(cbm_zova_repository_t *repo, const char *sql, zova_statement **out) { - return zova_database_prepare(&(zova_database_prepare_request){ - .db = repo->db, .sql = sql, .out_statement = out}) == ZOVA_OK && *out - ? 0 : -1; -} - -static int repo_bind_text(zova_statement *stmt, int index, const char *value) { - return zova_statement_bind_text(&(zova_statement_bind_text_request){ - .statement = stmt, .index = index, .data = (const uint8_t *)value, - .len = strlen(value)}) == ZOVA_OK ? 0 : -1; -} - -static int repo_bind_i64(zova_statement *stmt, int index, int64_t value) { - return zova_statement_bind_int64(&(zova_statement_bind_int64_request){ - .statement = stmt, .index = index, .value = value}) == ZOVA_OK ? 0 : -1; -} - -static int repo_step(zova_statement *stmt, zova_step_result *out) { - return zova_statement_step(&(zova_statement_step_request){ - .statement = stmt, .out_result = out}) == ZOVA_OK ? 0 : -1; -} - -static char *repo_column_text(zova_statement *stmt, int index) { - zova_text text = {0}; - if (zova_statement_column_text(&(zova_statement_column_text_request){ - .statement = stmt, .index = index, .out_text = &text}) != ZOVA_OK) - return NULL; - char *copy = repo_dup((const uint8_t *)(text.data ? text.data : ""), - text.data ? text.len : 0); - zova_text_free(&text); - return copy; -} - -static int64_t repo_column_i64(zova_statement *stmt, int index) { - int64_t value = 0; - (void)zova_statement_column_int64(&(zova_statement_column_int64_request){ - .statement = stmt, .index = index, .out_value = &value}); - return value; -} - -static double repo_column_double(zova_statement *stmt, int index) { - double value = 0.0; - (void)zova_statement_column_double(&(zova_statement_column_double_request){ - .statement = stmt, .index = index, .out_value = &value}); - return value; -} - -static int64_t stable_numeric_id(const char *value) { - uint64_t hash = 1469598103934665603ULL; - for (const unsigned char *p = (const unsigned char *)value; p && *p; ++p) { - hash = (hash ^ *p) * 1099511628211ULL; - } - return (int64_t)(hash & INT64_MAX); -} - -static int repo_edge_id(const char *workspace_id, const char *source, const char *type, - const char *target, const char *local_name, char *out, - size_t out_size) { - const char *parts[] = {source, type, target, local_name}; - cbm_sha256_ctx hash; - uint8_t digest[CBM_SHA256_DIGEST_LEN]; - static const char digits[] = "0123456789abcdef"; - char hex[33]; - if (!workspace_id || !source || !type || !target || !local_name || !out) return -1; - cbm_sha256_init(&hash); - for (size_t i = 0; i < sizeof(parts) / sizeof(parts[0]); i++) { - cbm_sha256_update(&hash, parts[i], strlen(parts[i])); - cbm_sha256_update(&hash, "\0", 1); - } - cbm_sha256_final(&hash, digest); - for (size_t i = 0; i < 16; i++) { - hex[i * 2] = digits[digest[i] >> 4]; - hex[i * 2 + 1] = digits[digest[i] & 0xf]; - } - hex[32] = '\0'; - return snprintf(out, out_size, "e:v1:%s:%s", workspace_id, hex) < (int)out_size ? 0 : -1; -} - -static int repo_graph_name(const cbm_zova_repository_t *repo, char *out, size_t out_size) { - return repo && cbm_zova_workspace_graph_name(repo->workspace_id, out, out_size) == 0 - ? 0 : -1; -} - -static int repo_numeric_id_compare(const void *left, const void *right) { - const repo_numeric_id_entry_t *a = left, *b = right; - if (a->numeric_id != b->numeric_id) return a->numeric_id < b->numeric_id ? -1 : 1; - return a->order < b->order ? -1 : a->order > b->order; -} - -static void repo_numeric_ids_clear(cbm_zova_repository_t *repo) { - free(repo->numeric_ids); - repo_string_arena_free(repo->numeric_id_strings); - repo->numeric_ids = NULL; - repo->numeric_id_count = 0; - repo->numeric_id_strings = NULL; -} - -/* One paged scan of the workspace graph; equal numeric ids keep scan order so - * the lookup returns the same node the former linear search found first. */ -static int repo_numeric_ids_build(cbm_zova_repository_t *repo) { - repo_numeric_ids_clear(repo); - char graph_name[REPO_GRAPH_NAME_CAP]; - if (repo_graph_name(repo, graph_name, sizeof(graph_name)) != 0) return CBM_STORE_ERR; - repo->numeric_id_strings = calloc(1, sizeof(*repo->numeric_id_strings)); - if (!repo->numeric_id_strings) return CBM_STORE_ERR; - size_t count = 0, cap = 0; - zova_graph_scan_cursor cursor = {0}; - for (;;) { - zova_graph_scan_results page = {0}; - zova_status status = zova_graph_scan(&(zova_graph_scan_request){ - .db = repo->db, .graph_name = graph_name, .node_after = cursor, - .node_limit = REPO_GRAPH_SCAN_PAGE, .edge_limit = 0, .out_results = &page}); - if (status != ZOVA_OK) goto error; - for (size_t i = 0; i < page.nodes_len; i++) { - const zova_graph_scan_node *node = &page.nodes[i]; - if (!node->node_id) continue; - if (count == cap) { - size_t next_cap = cap ? cap * 2 : REPO_GRAPH_SCAN_PAGE; - repo_numeric_id_entry_t *grown = - realloc(repo->numeric_ids, next_cap * sizeof(*grown)); - if (!grown) { zova_graph_scan_results_free(&page); goto error; } - repo->numeric_ids = grown; - cap = next_cap; - } - char *stable = repo_string_arena_dup(repo->numeric_id_strings, - (const uint8_t *)node->node_id, - strlen(node->node_id)); - if (!stable) { zova_graph_scan_results_free(&page); goto error; } - repo->numeric_ids[count] = (repo_numeric_id_entry_t){ - .numeric_id = stable_numeric_id(stable), .order = count, - .node_key = node->node_key, .stable_id = stable}; - count++; - } - if (page.nodes_len) { - const zova_graph_scan_node *last = &page.nodes[page.nodes_len - 1]; - cursor = (zova_graph_scan_cursor){.created_order = last->created_order, - .key = last->node_key}; - } - uint8_t more = page.has_more_nodes; - zova_graph_scan_results_free(&page); - if (!more) break; - } - if (count) qsort(repo->numeric_ids, count, sizeof(*repo->numeric_ids), - repo_numeric_id_compare); - repo->numeric_id_count = count; - return CBM_STORE_OK; -error: - repo_numeric_ids_clear(repo); - return CBM_STORE_ERR; -} - -/* First entry with numeric_id, or NULL; equal ids follow it contiguously. */ -static const repo_numeric_id_entry_t *repo_numeric_ids_find(cbm_zova_repository_t *repo, - int64_t numeric_id) { - if (!repo->numeric_id_strings && repo_numeric_ids_build(repo) != CBM_STORE_OK) - return NULL; - size_t lo = 0, hi = repo->numeric_id_count; - while (lo < hi) { - size_t mid = lo + (hi - lo) / 2; - if (repo->numeric_ids[mid].numeric_id < numeric_id) lo = mid + 1; - else hi = mid; - } - return lo < repo->numeric_id_count && repo->numeric_ids[lo].numeric_id == numeric_id - ? &repo->numeric_ids[lo] : NULL; -} - -static int repo_node_key_for_stable_id(cbm_zova_repository_t *repo, const char *stable_id, - int64_t *out_key) { - if (!repo || !stable_id || !stable_id[0] || !out_key) return -1; - *out_key = 0; - int64_t numeric_id = stable_numeric_id(stable_id); - const repo_numeric_id_entry_t *entry = repo_numeric_ids_find(repo, numeric_id); - if (!entry && !repo->numeric_id_strings) return -1; - const repo_numeric_id_entry_t *end = repo->numeric_ids + repo->numeric_id_count; - for (; entry && entry < end && entry->numeric_id == numeric_id; entry++) { - if (strcmp(entry->stable_id, stable_id) == 0) { - *out_key = entry->node_key; - return *out_key > 0 ? 0 : -1; - } - } - return 1; -} - -static int repo_native_nodes_by_key(cbm_zova_repository_t *repo, const int64_t *keys, - size_t count, zova_graph_keyed_node_results *out) { - char graph_name[REPO_GRAPH_NAME_CAP]; - if (!out || repo_graph_name(repo, graph_name, sizeof(graph_name)) != 0) return -1; - memset(out, 0, sizeof(*out)); - zova_status status = zova_graph_nodes_get_many_keyed(&(zova_graph_nodes_get_many_keyed_request){ - .db = repo->db, .graph_name = graph_name, .node_keys = keys, - .key_count = count, .out_results = out}); - if (status != ZOVA_OK || out->len != count) { - fprintf(stderr, "zova keyed node read failed status=%d requested=%zu returned=%zu\n", - (int)status, count, out->len); - return -1; - } - return 0; -} - -static int repo_degree_many(cbm_zova_repository_t *repo, const int64_t *keys, size_t count, - int direction, const char *edge_type, uint64_t *out) { - char graph_name[REPO_GRAPH_NAME_CAP]; - if (!out || repo_graph_name(repo, graph_name, sizeof(graph_name)) != 0) return -1; - return zova_graph_degree_many_keyed(&(zova_graph_degree_many_keyed_request){ - .db=repo->db,.graph_name=graph_name,.node_keys=keys,.node_count=count, - .direction=direction,.edge_type=edge_type,.out_degrees=out, - .out_degrees_capacity=count}) == ZOVA_OK ? 0 : -1; -} - -static double repo_elapsed_ms(struct timespec started) { - struct timespec finished; - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - return (double)(finished.tv_sec - started.tv_sec) * 1000.0 + - (double)(finished.tv_nsec - started.tv_nsec) / 1000000.0; -} - -cbm_zova_repository_t *cbm_zova_repository_open(const char *path, const char *project) { - if (!path || !path[0] || !project || !project[0]) return NULL; - char *stored_project = strdup(project); - if (!stored_project) return NULL; - cbm_zova_catalog_t *catalog = cbm_zova_catalog_open(path); - if (catalog) { - const char *selectors[] = {project}; - cbm_zova_catalog_scope_t scope = {0}; - if (cbm_zova_catalog_resolve(catalog, selectors, 1, false, &scope) == CBM_STORE_OK && - scope.count == 1) { - char *resolved = strdup(scope.projects[0].project); - if (!resolved) { - cbm_zova_catalog_scope_free(&scope); - cbm_zova_catalog_close(catalog); - free(stored_project); - return NULL; - } - free(stored_project); - stored_project = resolved; - } - cbm_zova_catalog_scope_free(&scope); - cbm_zova_catalog_close(catalog); - } - cbm_zova_repository_t *repo = calloc(1, sizeof(*repo)); - if (!repo) { - free(stored_project); - return NULL; - } - zova_message error = {0}; - if (zova_database_open_with_options(&(zova_database_open_options_request){ - .path = path, .flags = ZOVA_OPEN_READ_ONLY, .busy_timeout_ms = 5000, - .out_db = &repo->db, .out_error_message = &error}) != ZOVA_OK || !repo->db) { - zova_message_free(&error); - free(repo); - free(stored_project); - return NULL; - } - zova_message_free(&error); - if (cbm_zova_register_sql_functions(repo->db) != 0) { - cbm_zova_repository_close(repo); - free(stored_project); - return NULL; - } - zova_statement *stmt = NULL; - const char *sql = - "SELECT r.workspace_id,p.workspace_key,g.generation FROM cbm_projects_v1 p " - "JOIN cbm_workspace_registry r ON r.workspace_key=p.workspace_key " - "JOIN cbm_database_generation_v1 g ON g.workspace_key=p.workspace_key " - "WHERE p.project=?1 AND g.state='ready' AND g.generation=(SELECT MAX(g2.generation) " - "FROM cbm_database_generation_v1 g2 WHERE g2.workspace_key=p.workspace_key " - "AND g2.state='ready') AND NOT EXISTS (SELECT 1 FROM cbm_workspace_health_v1 h " - "WHERE h.workspace_key=p.workspace_key AND h.state='rebuild_required')"; - if (repo_prepare(repo, sql, &stmt) != 0 || repo_bind_text(stmt, 1, stored_project) != 0) { - if (stmt) (void)zova_statement_finalize(stmt); - cbm_zova_repository_close(repo); - free(stored_project); - return NULL; - } - zova_step_result step = ZOVA_STEP_DONE; - int ok = repo_step(stmt, &step) == 0 && step == ZOVA_STEP_ROW; - char *workspace = ok ? repo_column_text(stmt, 0) : NULL; - if (ok) { - repo->workspace_key = repo_column_i64(stmt, 1); - repo->generation = repo_column_i64(stmt, 2); - } - zova_step_result second = ZOVA_STEP_DONE; - if (ok && repo_step(stmt, &second) != 0) ok = 0; - if (second == ZOVA_STEP_ROW) ok = 0; - (void)zova_statement_finalize(stmt); - if (!ok || !workspace || !workspace[0] || strlen(workspace) >= sizeof(repo->workspace_id) || - strlen(project) >= sizeof(repo->project)) { - free(workspace); - cbm_zova_repository_close(repo); - free(stored_project); - return NULL; - } - strcpy(repo->workspace_id, workspace); - strcpy(repo->project, project); - free(workspace); - free(stored_project); - return repo; -} - -void cbm_zova_repository_close(cbm_zova_repository_t *repo) { - if (!repo) return; - if (repo->db) (void)zova_database_close(repo->db); - free(repo->numeric_ids); - repo_string_arena_free(repo->numeric_id_strings); - free(repo); -} - -static void catalog_project_free(cbm_zova_catalog_project_t *project) { - if (!project) return; - free(project->selector); - free(project->project); - free(project->root_path); - free(project->indexed_at); - free(project->model_fingerprint); - free(project->health_reason); - memset(project, 0, sizeof(*project)); -} - -static int catalog_project_copy(cbm_zova_catalog_project_t *destination, - const cbm_zova_catalog_project_t *source) { - if (!destination || !source) return -1; - *destination = (cbm_zova_catalog_project_t){ - .workspace_key = source->workspace_key, - .vector_dimensions = source->vector_dimensions, - .generation = source->generation, - .ready = source->ready, - .healthy = source->healthy, - }; - memcpy(destination->workspace_id, source->workspace_id, - sizeof(destination->workspace_id)); -#define CATALOG_COPY(field) \ - do { \ - destination->field = strdup(source->field ? source->field : ""); \ - if (!destination->field) goto error; \ - } while (0) - CATALOG_COPY(selector); - CATALOG_COPY(project); - CATALOG_COPY(root_path); - CATALOG_COPY(indexed_at); - CATALOG_COPY(model_fingerprint); - CATALOG_COPY(health_reason); -#undef CATALOG_COPY - return 0; -error: - catalog_project_free(destination); - return -1; -} - -static bool catalog_selector_used(const cbm_zova_catalog_t *catalog, int before, - const char *selector) { - for (int i = 0; i < before; i++) { - if (catalog->projects[i].selector && - strcmp(catalog->projects[i].selector, selector) == 0) { - return true; - } - } - return false; -} - -static char *catalog_selector_disambiguate(const char *selector, const char *root_path, - int64_t workspace_key) { - uint64_t hash = 1469598103934665603ULL; - for (const unsigned char *p = (const unsigned char *)root_path; p && *p; p++) - hash = (hash ^ *p) * 1099511628211ULL; - size_t needed = strlen(selector) + 1 + 16 + 1 + 20 + 1; - char *result = malloc(needed); - if (!result) return NULL; - snprintf(result, needed, "%s-%016llx-%lld", selector, (unsigned long long)hash, - (long long)workspace_key); - return result; -} - -static int catalog_assign_selectors(cbm_zova_catalog_t *catalog) { - for (int i = 0; i < catalog->count; i++) { - size_t parent_levels = 0; - char *selector = cbm_project_selector_from_path(catalog->projects[i].root_path, - parent_levels); - if (!selector) return -1; - while (catalog_selector_used(catalog, i, selector)) { - char *next = cbm_project_selector_from_path(catalog->projects[i].root_path, - ++parent_levels); - if (!next) { - free(selector); - return -1; - } - if (strcmp(next, selector) == 0) { - free(next); - next = catalog_selector_disambiguate( - selector, catalog->projects[i].root_path, - catalog->projects[i].workspace_key); - if (!next) { - free(selector); - return -1; - } - } - free(selector); - selector = next; - } - catalog->projects[i].selector = selector; - } - return 0; -} - -static int catalog_project_selector_compare(const void *left, const void *right) { - const cbm_zova_catalog_project_t *a = left; - const cbm_zova_catalog_project_t *b = right; - return strcmp(a->selector, b->selector); -} - -cbm_zova_catalog_t *cbm_zova_catalog_open(const char *path) { - if (!path || !path[0]) return NULL; - cbm_zova_catalog_t *catalog = calloc(1, sizeof(*catalog)); - if (!catalog) return NULL; - zova_message error = {0}; - if (zova_database_open_with_options(&(zova_database_open_options_request){ - .path = path, .flags = ZOVA_OPEN_READ_ONLY, .busy_timeout_ms = 5000, - .out_db = &catalog->db, .out_error_message = &error}) != ZOVA_OK || !catalog->db) { - zova_message_free(&error); - cbm_zova_catalog_close(catalog); - return NULL; - } - zova_message_free(&error); - if (cbm_zova_register_sql_functions(catalog->db) != 0 || - zova_database_exec(&(zova_database_exec_request){ - .db = catalog->db, .sql = "BEGIN"}) != ZOVA_OK) { - cbm_zova_catalog_close(catalog); - return NULL; - } - catalog->transaction_open = true; - - cbm_zova_repository_t reader = {.db = catalog->db}; - zova_statement *statement = NULL; - const char *sql = - "SELECT r.workspace_id,r.workspace_key,r.canonical_root,COALESCE(p.project,'')," - "COALESCE(p.indexed_at,''),COALESCE(s.model_fingerprint,'')," - "COALESCE(s.vector_dimensions,0),r.active_generation," - "CASE WHEN g.state='ready' THEN 1 ELSE 0 END," - "COALESCE(h.state,'healthy'),COALESCE(h.reason,'') " - "FROM cbm_workspace_registry r " - "LEFT JOIN cbm_projects_v1 p ON p.workspace_key=r.workspace_key " - "LEFT JOIN cbm_workspace_index_state_v1 s ON s.workspace_key=r.workspace_key " - "LEFT JOIN cbm_database_generation_v1 g ON g.workspace_key=r.workspace_key " - "AND g.generation=r.active_generation " - "LEFT JOIN cbm_workspace_health_v1 h ON h.workspace_key=r.workspace_key " - "ORDER BY r.workspace_key"; - if (repo_prepare(&reader, sql, &statement) != 0) { - cbm_zova_catalog_close(catalog); - return NULL; - } - int capacity = 8; - catalog->projects = calloc((size_t)capacity, sizeof(*catalog->projects)); - if (!catalog->projects) { - (void)zova_statement_finalize(statement); - cbm_zova_catalog_close(catalog); - return NULL; - } - for (;;) { - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(statement, &step) != 0) goto error; - if (step == ZOVA_STEP_DONE) break; - if (catalog->count == capacity) { - if (capacity > INT_MAX / 2) goto error; - int old_capacity = capacity; - capacity *= 2; - cbm_zova_catalog_project_t *grown = - realloc(catalog->projects, (size_t)capacity * sizeof(*grown)); - if (!grown) goto error; - catalog->projects = grown; - memset(catalog->projects + old_capacity, 0, - (size_t)(capacity - old_capacity) * sizeof(*catalog->projects)); - } - cbm_zova_catalog_project_t *project = &catalog->projects[catalog->count]; - char *workspace_id = repo_column_text(statement, 0); - project->workspace_key = repo_column_i64(statement, 1); - project->root_path = repo_column_text(statement, 2); - project->project = repo_column_text(statement, 3); - project->indexed_at = repo_column_text(statement, 4); - project->model_fingerprint = repo_column_text(statement, 5); - project->vector_dimensions = (int)repo_column_i64(statement, 6); - project->generation = repo_column_i64(statement, 7); - project->ready = repo_column_i64(statement, 8) != 0; - char *health = repo_column_text(statement, 9); - project->health_reason = repo_column_text(statement, 10); - project->healthy = health && strcmp(health, "rebuild_required") != 0; - if (!workspace_id || !workspace_id[0] || - strlen(workspace_id) >= sizeof(project->workspace_id) || - !project->root_path || !project->project || !project->indexed_at || - !project->model_fingerprint || !health || !project->health_reason) { - free(workspace_id); - free(health); - catalog_project_free(project); - goto error; - } - strcpy(project->workspace_id, workspace_id); - free(workspace_id); - free(health); - catalog->count++; - } - (void)zova_statement_finalize(statement); - if (catalog_assign_selectors(catalog) != 0) { - cbm_zova_catalog_close(catalog); - return NULL; - } - if (catalog->count > 1) { - qsort(catalog->projects, (size_t)catalog->count, sizeof(*catalog->projects), - catalog_project_selector_compare); - } - return catalog; -error: - (void)zova_statement_finalize(statement); - cbm_zova_catalog_close(catalog); - return NULL; -} - -void cbm_zova_catalog_close(cbm_zova_catalog_t *catalog) { - if (!catalog) return; - if (catalog->db && catalog->transaction_open) { - (void)zova_database_exec(&(zova_database_exec_request){ - .db = catalog->db, .sql = "ROLLBACK"}); - } - for (int i = 0; i < catalog->count; i++) catalog_project_free(&catalog->projects[i]); - free(catalog->projects); - if (catalog->db) (void)zova_database_close(catalog->db); - free(catalog); -} - -void cbm_zova_catalog_scope_free(cbm_zova_catalog_scope_t *scope) { - if (!scope) return; - for (int i = 0; i < scope->count; i++) catalog_project_free(&scope->projects[i]); - for (int i = 0; i < scope->excluded_count; i++) - catalog_project_free(&scope->excluded_projects[i]); - free(scope->projects); - free(scope->excluded_projects); - memset(scope, 0, sizeof(*scope)); -} - -int cbm_zova_catalog_list(cbm_zova_catalog_t *catalog, cbm_zova_catalog_scope_t *out) { - if (!catalog || !out) return CBM_STORE_ERR; - memset(out, 0, sizeof(*out)); - if (catalog->count == 0) return CBM_STORE_OK; - out->projects = calloc((size_t)catalog->count, sizeof(*out->projects)); - if (!out->projects) return CBM_STORE_ERR; - for (int i = 0; i < catalog->count; i++) { - if (catalog_project_copy(&out->projects[out->count], &catalog->projects[i]) != 0) { - cbm_zova_catalog_scope_free(out); - return CBM_STORE_ERR; - } - out->count++; - } - return CBM_STORE_OK; -} - -const char *cbm_zova_catalog_error(const cbm_zova_catalog_t *catalog) { - return catalog && catalog->error[0] ? catalog->error : ""; -} - -int cbm_zova_catalog_resolve(cbm_zova_catalog_t *catalog, const char *const *selectors, - int selector_count, bool wildcard, - cbm_zova_catalog_scope_t *out) { - if (!catalog || !out || (!wildcard && (!selectors || selector_count <= 0))) - return CBM_STORE_ERR; - catalog->error[0] = '\0'; - memset(out, 0, sizeof(*out)); - int wanted = wildcard ? catalog->count : selector_count; - out->projects = wanted ? calloc((size_t)wanted, sizeof(*out->projects)) : NULL; - if (wanted && !out->projects) return CBM_STORE_ERR; - - if (wildcard) { - out->excluded_projects = wanted ? calloc((size_t)wanted, sizeof(*out->excluded_projects)) - : NULL; - if (wanted && !out->excluded_projects) goto error; - for (int i = 0; i < catalog->count; i++) { - if (!catalog->projects[i].ready || !catalog->projects[i].healthy) { - if (catalog_project_copy(&out->excluded_projects[out->excluded_count], - &catalog->projects[i]) != 0) - goto error; - out->excluded_count++; - } else { - if (catalog_project_copy(&out->projects[out->count], &catalog->projects[i]) != 0) - goto error; - out->count++; - } - } - } else { - for (int i = 0; i < selector_count; i++) { - const cbm_zova_catalog_project_t *match = NULL; - for (int j = 0; j < catalog->count; j++) { - if (strcmp(catalog->projects[j].selector, selectors[i]) == 0) { - match = &catalog->projects[j]; - break; - } - } - if (!match) { - snprintf(catalog->error, sizeof(catalog->error), - "project '%s' was not found", selectors[i]); - cbm_zova_catalog_scope_free(out); - return CBM_STORE_NOT_FOUND; - } - if (!match->ready || !match->healthy) { - snprintf(catalog->error, sizeof(catalog->error), "project '%s' is unavailable: %s", - selectors[i], !match->ready ? "no ready generation" - : (match->health_reason[0] - ? match->health_reason - : "workspace unhealthy")); - goto error; - } - if (catalog_project_copy(&out->projects[out->count], match) != 0) goto error; - out->count++; - } - } - return CBM_STORE_OK; -error: - cbm_zova_catalog_scope_free(out); - return CBM_STORE_ERR; -} - -static char *catalog_scope_cte(const cbm_zova_catalog_scope_t *scope, int *next_bind) { - if (!scope || scope->count <= 0 || !next_bind) return NULL; - size_t capacity = 64 + (size_t)scope->count * 32; - char *sql = malloc(capacity); - if (!sql) return NULL; - size_t used = (size_t)snprintf(sql, capacity, - "WITH scope(workspace_key,selector) AS (VALUES "); - int bind = 3; - for (int i = 0; i < scope->count; i++) { - int written = snprintf(sql + used, capacity - used, "%s(?%d,?%d)", - i ? "," : "", bind, bind + 1); - if (written < 0 || (size_t)written >= capacity - used) { - free(sql); - return NULL; - } - used += (size_t)written; - bind += 2; - } - if (used + 3 > capacity) { - free(sql); - return NULL; - } - memcpy(sql + used, ") ", 3); - *next_bind = bind; - return sql; -} - -static int catalog_bind_fts_scope(zova_statement *statement, - const cbm_zova_catalog_scope_t *scope, - const char *query, const char *file_pattern) { - if (repo_bind_text(statement, 1, query) != 0) return -1; - zova_status status = file_pattern && file_pattern[0] - ? zova_statement_bind_text(&(zova_statement_bind_text_request){ - .statement = statement, .index = 2, - .data = (const uint8_t *)file_pattern, - .len = strlen(file_pattern)}) - : zova_statement_bind_null(&(zova_statement_bind_null_request){ - .statement = statement, .index = 2}); - if (status != ZOVA_OK) return -1; - int bind = 3; - for (int i = 0; i < scope->count; i++) { - if (repo_bind_i64(statement, bind++, scope->projects[i].workspace_key) != 0 || - repo_bind_text(statement, bind++, scope->projects[i].selector) != 0) { - return -1; - } - } - return 0; -} - -int cbm_zova_catalog_search_fts(cbm_zova_catalog_t *catalog, - const cbm_zova_catalog_scope_t *scope, - const char *query, const char *file_pattern, - int limit, int offset, cbm_search_output_t *out) { - if (!catalog || !scope || scope->count <= 0 || !query || !query[0] || !out || offset < 0) - return CBM_STORE_ERR; - memset(out, 0, sizeof(*out)); - if (limit <= 0) limit = 10; - - int next_bind = 0; - char *cte = catalog_scope_cte(scope, &next_bind); - if (!cte) return CBM_STORE_ERR; - const char *count_tail = - "SELECT count(*) FROM scope sc JOIN cbm_nodes_v1 n " - "ON n.workspace_key=sc.workspace_key " - "JOIN cbm_nodes_fts_v1 f ON f.rowid=n.zova_node_key " - "JOIN cbm_files_v1 p ON p.file_key=n.file_key " - "WHERE cbm_nodes_fts_v1 MATCH ?1 " - "AND n.label NOT IN ('File','Folder','Module','Section','Variable','Project') " - "AND (?2 IS NULL OR p.file_path GLOB ?2)"; - size_t count_len = strlen(cte) + strlen(count_tail) + 1; - char *count_sql = malloc(count_len); - if (!count_sql) { - free(cte); - return CBM_STORE_ERR; - } - snprintf(count_sql, count_len, "%s%s", cte, count_tail); - cbm_zova_repository_t reader = {.db = catalog->db}; - zova_statement *statement = NULL; - int rc = repo_prepare(&reader, count_sql, &statement); - if (rc == 0) rc = catalog_bind_fts_scope(statement, scope, query, file_pattern); - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0 && (repo_step(statement, &step) != 0 || step != ZOVA_STEP_ROW)) rc = -1; - int64_t total = rc == 0 ? repo_column_i64(statement, 0) : 0; - if (statement) (void)zova_statement_finalize(statement); - statement = NULL; - free(count_sql); - if (rc != 0 || total < 0 || total > INT_MAX) { - free(cte); - return CBM_STORE_ERR; - } - - const char *data_tail = - "SELECT n.workspace_key,n.zova_node_key,sc.selector,n.label,n.name," - "n.qualified_name,p.file_path,n.start_line,n.end_line,n.properties," - "(bm25(cbm_nodes_fts_v1)-CASE WHEN n.label IN ('Function','Method') THEN 10.0 " - "WHEN n.label='Route' THEN 8.0 WHEN n.label IN " - "('Class','Interface','Type','Enum') THEN 5.0 ELSE 0.0 END) AS rank " - "FROM scope sc JOIN cbm_nodes_v1 n ON n.workspace_key=sc.workspace_key " - "JOIN cbm_nodes_fts_v1 f ON f.rowid=n.zova_node_key " - "JOIN cbm_files_v1 p ON p.file_key=n.file_key " - "WHERE cbm_nodes_fts_v1 MATCH ?1 " - "AND n.label NOT IN ('File','Folder','Module','Section','Variable','Project') " - "AND (?2 IS NULL OR p.file_path GLOB ?2) " - "ORDER BY rank,sc.selector,n.qualified_name,n.zova_node_key "; - size_t data_capacity = strlen(cte) + strlen(data_tail) + 80; - char *data_sql = malloc(data_capacity); - if (!data_sql) { - free(cte); - return CBM_STORE_ERR; - } - snprintf(data_sql, data_capacity, "%s%sLIMIT ?%d OFFSET ?%d", cte, data_tail, - next_bind, next_bind + 1); - free(cte); - if (repo_prepare(&reader, data_sql, &statement) != 0 || - catalog_bind_fts_scope(statement, scope, query, file_pattern) != 0 || - repo_bind_i64(statement, next_bind, limit) != 0 || - repo_bind_i64(statement, next_bind + 1, offset) != 0) { - free(data_sql); - if (statement) (void)zova_statement_finalize(statement); - return CBM_STORE_ERR; - } - free(data_sql); - - int capacity = limit < 64 ? limit : 64; - if (capacity < 1) capacity = 1; - cbm_search_result_t *results = calloc((size_t)capacity, sizeof(*results)); - int64_t *keys = calloc((size_t)capacity, sizeof(*keys)); - int64_t *workspace_keys = calloc((size_t)capacity, sizeof(*workspace_keys)); - int count = 0; - if (!results || !keys || !workspace_keys) goto search_error; - for (;;) { - step = ZOVA_STEP_DONE; - if (repo_step(statement, &step) != 0) goto search_error; - if (step == ZOVA_STEP_DONE) break; - if (count == capacity) { - int old_capacity = capacity; - if (capacity > INT_MAX / 2) goto search_error; - capacity *= 2; - cbm_search_result_t *grown_results = - realloc(results, (size_t)capacity * sizeof(*grown_results)); - int64_t *grown_keys = realloc(keys, (size_t)capacity * sizeof(*grown_keys)); - int64_t *grown_workspace_keys = - realloc(workspace_keys, (size_t)capacity * sizeof(*grown_workspace_keys)); - if (!grown_results || !grown_keys || !grown_workspace_keys) { - if (grown_results) results = grown_results; - if (grown_keys) keys = grown_keys; - if (grown_workspace_keys) workspace_keys = grown_workspace_keys; - goto search_error; - } - results = grown_results; - keys = grown_keys; - workspace_keys = grown_workspace_keys; - memset(results + old_capacity, 0, - (size_t)(capacity - old_capacity) * sizeof(*results)); - } - workspace_keys[count] = repo_column_i64(statement, 0); - keys[count] = repo_column_i64(statement, 1); - cbm_node_t *node = &results[count].node; - node->project = repo_column_text(statement, 2); - node->label = repo_column_text(statement, 3); - node->name = repo_column_text(statement, 4); - node->qualified_name = repo_column_text(statement, 5); - node->file_path = repo_column_text(statement, 6); - node->start_line = (int)repo_column_i64(statement, 7); - node->end_line = (int)repo_column_i64(statement, 8); - node->properties_json = repo_column_text(statement, 9); - results[count].rank = repo_column_double(statement, 10); - if (!node->project || !node->label || !node->name || !node->qualified_name || - !node->file_path || !node->properties_json) goto search_error; - count++; - } - (void)zova_statement_finalize(statement); - statement = NULL; - - for (int p = 0; p < scope->count; p++) { - int project_count = 0; - for (int i = 0; i < count; i++) { - if (workspace_keys[i] == scope->projects[p].workspace_key) project_count++; - } - if (!project_count) continue; - int64_t *project_keys = malloc((size_t)project_count * sizeof(*project_keys)); - int *positions = malloc((size_t)project_count * sizeof(*positions)); - if (!project_keys || !positions) { - free(project_keys); - free(positions); - goto search_error; - } - int written = 0; - for (int i = 0; i < count; i++) { - if (workspace_keys[i] != scope->projects[p].workspace_key) continue; - project_keys[written] = keys[i]; - positions[written++] = i; - } - cbm_zova_repository_t workspace = {.db = catalog->db}; - workspace.workspace_key = scope->projects[p].workspace_key; - strcpy(workspace.workspace_id, scope->projects[p].workspace_id); - zova_graph_keyed_node_results native = {0}; - if (repo_native_nodes_by_key(&workspace, project_keys, (size_t)project_count, - &native) != 0) { - free(project_keys); - free(positions); - goto search_error; - } - for (int i = 0; i < project_count; i++) { - int position = positions[i]; - if (!native.items[i].found || !native.items[i].node_id || !native.items[i].kind) { - zova_graph_keyed_node_results_free(&native); - free(project_keys); - free(positions); - goto search_error; - } - results[position].node.id = stable_numeric_id(native.items[i].node_id); - free((char *)results[position].node.label); - results[position].node.label = - repo_dup((const uint8_t *)native.items[i].kind, native.items[i].kind_len); - if (!results[position].node.label) { - zova_graph_keyed_node_results_free(&native); - free(project_keys); - free(positions); - goto search_error; - } - } - zova_graph_keyed_node_results_free(&native); - free(project_keys); - free(positions); - } - free(keys); - free(workspace_keys); - out->results = results; - out->count = count; - out->total = (int)total; - return CBM_STORE_OK; - -search_error: - if (statement) (void)zova_statement_finalize(statement); - free(keys); - free(workspace_keys); - cbm_store_search_free(&(cbm_search_output_t){.results = results, .count = count}); - return CBM_STORE_ERR; -} - -static int catalog_search_result_compare(const void *left_ptr, const void *right_ptr) { - const cbm_search_result_t *left = left_ptr; - const cbm_search_result_t *right = right_ptr; - int compared = strcmp(left->node.name ? left->node.name : "", - right->node.name ? right->node.name : ""); - if (compared != 0) return compared; - compared = strcmp(left->node.qualified_name ? left->node.qualified_name : "", - right->node.qualified_name ? right->node.qualified_name : ""); - if (compared != 0) return compared; - compared = strcmp(left->node.project ? left->node.project : "", - right->node.project ? right->node.project : ""); - if (compared != 0) return compared; - return (left->node.id > right->node.id) - (left->node.id < right->node.id); -} - -int cbm_zova_catalog_search(cbm_zova_catalog_t *catalog, - const cbm_zova_catalog_scope_t *scope, - const cbm_search_params_t *params, cbm_search_output_t *out) { - if (!catalog || !scope || scope->count <= 0 || !params || !out || params->offset < 0) - return CBM_STORE_ERR; - memset(out, 0, sizeof(*out)); - cbm_search_result_t *combined = NULL; - int combined_count = 0; - int combined_capacity = 0; - - for (int p = 0; p < scope->count; p++) { - cbm_zova_repository_t repository = {.db = catalog->db}; - repository.workspace_key = scope->projects[p].workspace_key; - repository.generation = scope->projects[p].generation; - strcpy(repository.workspace_id, scope->projects[p].workspace_id); - if (strlen(scope->projects[p].project) >= sizeof(repository.project)) goto error; - strcpy(repository.project, scope->projects[p].project); - - cbm_search_params_t local_params = *params; - local_params.project = scope->projects[p].project; - local_params.limit = INT_MAX; - local_params.offset = 0; - local_params.include_connected = false; - cbm_search_output_t local = {0}; - if (cbm_zova_repository_search(&repository, repository.workspace_id, - &local_params, &local) != CBM_STORE_OK) { - cbm_store_search_free(&local); - goto error; - } - if (local.count > INT_MAX - combined_count) { - cbm_store_search_free(&local); - goto error; - } - int needed = combined_count + local.count; - if (needed > combined_capacity) { - int grown_capacity = combined_capacity ? combined_capacity : 64; - while (grown_capacity < needed) { - if (grown_capacity > INT_MAX / 2) { - grown_capacity = needed; - break; - } - grown_capacity *= 2; - } - cbm_search_result_t *grown = - realloc(combined, (size_t)grown_capacity * sizeof(*grown)); - if (!grown) { - cbm_store_search_free(&local); - goto error; - } - combined = grown; - memset(combined + combined_capacity, 0, - (size_t)(grown_capacity - combined_capacity) * sizeof(*combined)); - combined_capacity = grown_capacity; - } - for (int i = 0; i < local.count; i++) { - char *selector = strdup(scope->projects[p].selector); - if (!selector) { - cbm_store_search_free(&local); - goto error; - } - free((char *)local.results[i].node.project); - local.results[i].node.project = selector; - combined[combined_count++] = local.results[i]; - memset(&local.results[i], 0, sizeof(local.results[i])); - } - cbm_store_search_free(&local); - } - - if (combined_count > 1) { - qsort(combined, (size_t)combined_count, sizeof(*combined), - catalog_search_result_compare); - } - int limit = params->limit > 0 ? params->limit : 10; - int available = params->offset < combined_count ? combined_count - params->offset : 0; - int page_count = available < limit ? available : limit; - cbm_search_result_t *page = - page_count ? calloc((size_t)page_count, sizeof(*page)) : NULL; - if (page_count && !page) goto error; - for (int i = 0; i < page_count; i++) { - int source = params->offset + i; - page[i] = combined[source]; - memset(&combined[source], 0, sizeof(combined[source])); - } - cbm_store_search_free( - &(cbm_search_output_t){.results = combined, .count = combined_count}); - out->results = page; - out->count = page_count; - out->total = combined_count; - return CBM_STORE_OK; -error: - cbm_store_search_free( - &(cbm_search_output_t){.results = combined, .count = combined_count}); - return CBM_STORE_ERR; -} - -int cbm_zova_catalog_search_semantic( - cbm_zova_catalog_t *catalog, const char *path, const cbm_zova_catalog_scope_t *scope, - const char **keywords, int keyword_count, int limit, int offset, - cbm_vector_result_t **out, int *out_count, int *out_total) { - if (!catalog || !path || !scope || scope->count <= 0 || !keywords || keyword_count <= 0 || - !out || !out_count || !out_total) { - return CBM_STORE_ERR; - } - *out_total = 0; - const char *model = scope->projects[0].model_fingerprint; - int dimensions = scope->projects[0].vector_dimensions; - if (!model || !model[0] || dimensions <= 0) return CBM_ZOVA_CATALOG_INCOMPATIBLE_MODELS; - for (int i = 0; i < scope->count; i++) { - const cbm_zova_catalog_project_t *project = &scope->projects[i]; - if (!project->model_fingerprint || strcmp(project->model_fingerprint, model) != 0 || - project->vector_dimensions != dimensions) { - return CBM_ZOVA_CATALOG_INCOMPATIBLE_MODELS; - } - } - - zova_statement *count_statement = NULL; - int rc = CBM_STORE_OK; - if (repo_prepare(&(cbm_zova_repository_t){.db = catalog->db}, - "SELECT node_vector_rows FROM cbm_generation_integrity_v2 " - "WHERE workspace_key=?1 AND generation=?2", &count_statement) != 0) { - rc = CBM_STORE_ERR; - } - int *vector_counts = calloc((size_t)scope->count, sizeof(*vector_counts)); - if (!vector_counts) rc = CBM_STORE_ERR; - int64_t total = 0; - for (int i = 0; rc == CBM_STORE_OK && i < scope->count; i++) { - if (zova_statement_reset(count_statement) != ZOVA_OK || - zova_statement_clear_bindings(count_statement) != ZOVA_OK || - repo_bind_i64(count_statement, 1, scope->projects[i].workspace_key) != 0 || - repo_bind_i64(count_statement, 2, scope->projects[i].generation) != 0) { - rc = CBM_STORE_ERR; - break; - } - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(count_statement, &step) != 0 || step != ZOVA_STEP_ROW) { - rc = CBM_STORE_ERR; - break; - } - int64_t count = repo_column_i64(count_statement, 0); - if (count < 0 || total > INT_MAX - count) { - rc = CBM_STORE_ERR; - break; - } - vector_counts[i] = (int)count; - total += count; - } - if (count_statement) (void)zova_statement_finalize(count_statement); - if (rc != CBM_STORE_OK) { - free(vector_counts); - return rc; - } - *out_total = (int)total; - - cbm_zova_vector_workspace_t *workspaces = - calloc((size_t)scope->count, sizeof(*workspaces)); - if (!workspaces) { - free(vector_counts); - return CBM_STORE_ERR; - } - int workspace_count = 0; - for (int i = 0; i < scope->count; i++) { - if (vector_counts[i] == 0) continue; - const cbm_zova_catalog_project_t *project = &scope->projects[i]; - workspaces[workspace_count++] = (cbm_zova_vector_workspace_t){ - .workspace_key = project->workspace_key, - .workspace_id = project->workspace_id, - .project = project->project, - .selector = project->selector, - .model_fingerprint = project->model_fingerprint, - .vector_dimensions = project->vector_dimensions, - .generation = project->generation, - }; - } - free(vector_counts); - if (workspace_count > 0) { - rc = cbm_store_zova_multi_vector_search( - path, workspaces, workspace_count, keywords, keyword_count, - limit, offset, out, out_count); - } else { - *out = NULL; - *out_count = 0; - } - free(workspaces); - - cbm_zova_catalog_t *current = rc == CBM_STORE_OK ? cbm_zova_catalog_open(path) : NULL; - const char **selectors = calloc((size_t)scope->count, sizeof(*selectors)); - cbm_zova_catalog_scope_t verified = {0}; - if (!current || !selectors) rc = CBM_STORE_ERR; - for (int i = 0; rc == CBM_STORE_OK && i < scope->count; i++) - selectors[i] = scope->projects[i].selector; - if (rc == CBM_STORE_OK && - cbm_zova_catalog_resolve(current, selectors, scope->count, false, &verified) != - CBM_STORE_OK) { - rc = CBM_ZOVA_CATALOG_STALE_SCOPE; - } - for (int i = 0; rc == CBM_STORE_OK && i < scope->count; i++) { - if (verified.projects[i].workspace_key != scope->projects[i].workspace_key || - verified.projects[i].generation != scope->projects[i].generation || - verified.projects[i].vector_dimensions != scope->projects[i].vector_dimensions || - strcmp(verified.projects[i].model_fingerprint, - scope->projects[i].model_fingerprint) != 0) { - rc = CBM_ZOVA_CATALOG_STALE_SCOPE; - } - } - free(selectors); - cbm_zova_catalog_scope_free(&verified); - cbm_zova_catalog_close(current); - if (rc != CBM_STORE_OK) { - cbm_store_free_vector_results(*out, *out_count); - *out = NULL; - *out_count = 0; - } - return rc; -} - -const char *cbm_zova_repository_workspace_id(const cbm_zova_repository_t *repo) { - return repo ? repo->workspace_id : ""; -} - -static int repo_workspace_required(const cbm_zova_repository_t *repo, - const char *workspace_id) { - return repo && cbm_zova_workspace_id_validate(workspace_id) == 0 && - strcmp(repo->workspace_id, workspace_id) == 0 - ? CBM_STORE_OK - : CBM_STORE_ERR; -} - -static int hydrate_node_key(cbm_zova_repository_t *repo, const char *workspace_id, - int64_t node_key, cbm_node_t *out) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || node_key <= 0 || !out) - return CBM_STORE_ERR; - zova_statement *stmt = NULL; - const char *sql = "SELECT n.name,n.qualified_name,f.file_path,n.start_line,n.end_line," - "n.properties FROM cbm_nodes_v1 n JOIN cbm_files_v1 f ON f.file_key=n.file_key " - "WHERE n.workspace_key=?1 AND n.zova_node_key=?2"; - if (repo_prepare(repo, sql, &stmt) != 0 || - repo_bind_i64(stmt, 1, repo->workspace_key) != 0 || - repo_bind_i64(stmt, 2, node_key) != 0) goto error; - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(stmt, &step) != 0 || step != ZOVA_STEP_ROW) { - (void)zova_statement_finalize(stmt); - return CBM_STORE_NOT_FOUND; - } - zova_graph_keyed_node_results native = {0}; - if (repo_native_nodes_by_key(repo, &node_key, 1, &native) != 0 || - !native.items[0].found || !native.items[0].node_id || !native.items[0].kind) { - zova_graph_keyed_node_results_free(&native); - goto error; - } - memset(out, 0, sizeof(*out)); - out->id = stable_numeric_id(native.items[0].node_id); - out->project = repo_dup((const uint8_t *)repo->project, strlen(repo->project)); - out->label = repo_dup((const uint8_t *)native.items[0].kind, - native.items[0].kind_len); - out->name = repo_column_text(stmt, 0); - out->qualified_name = repo_column_text(stmt, 1); - out->file_path = repo_column_text(stmt, 2); - out->start_line = (int)repo_column_i64(stmt, 3); - out->end_line = (int)repo_column_i64(stmt, 4); - out->properties_json = repo_column_text(stmt, 5); - zova_graph_keyed_node_results_free(&native); - (void)zova_statement_finalize(stmt); - return CBM_STORE_OK; -error: - if (stmt) (void)zova_statement_finalize(stmt); - return CBM_STORE_ERR; -} - -int cbm_zova_repository_find_node_by_qn(cbm_zova_repository_t *repo, const char *workspace_id, - const char *qualified_name, cbm_node_t *out) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || !qualified_name || !out) - return CBM_STORE_ERR; - zova_statement *stmt = NULL; - int64_t key = 0; - int rc = repo_prepare(repo, "SELECT zova_node_key FROM cbm_nodes_v1 " - "WHERE workspace_key=?1 AND qualified_name=?2", &stmt); - if (rc == 0) rc = repo_bind_i64(stmt, 1, repo->workspace_key); - if (rc == 0) rc = repo_bind_text(stmt, 2, qualified_name); - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0 && repo_step(stmt, &step) == 0 && step == ZOVA_STEP_ROW) - key = repo_column_i64(stmt, 0); - if (stmt) (void)zova_statement_finalize(stmt); - return key > 0 ? hydrate_node_key(repo, workspace_id, key, out) : CBM_STORE_NOT_FOUND; -} - -int cbm_zova_repository_find_node_by_stable_id(cbm_zova_repository_t *repo, - const char *workspace_id, - const char *stable_id, cbm_node_t *out) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || !stable_id || !out) - return CBM_STORE_ERR; - int64_t key = 0; - int rc = repo_node_key_for_stable_id(repo, stable_id, &key); - return rc == 0 ? hydrate_node_key(repo, workspace_id, key, out) - : rc == 1 ? CBM_STORE_NOT_FOUND : CBM_STORE_ERR; -} - -typedef struct { - const char *stable_id; - int input_index; - int64_t node_key; -} repo_stable_node_request_t; - -static int repo_stable_node_request_compare(const void *left_ptr, const void *right_ptr) { - const repo_stable_node_request_t *left = left_ptr; - const repo_stable_node_request_t *right = right_ptr; - return strcmp(left->stable_id, right->stable_id); -} - -static repo_stable_node_request_t *repo_stable_node_request_find( - repo_stable_node_request_t *requests, int count, const char *stable_id) { - repo_stable_node_request_t key = {.stable_id = stable_id}; - return bsearch(&key, requests, (size_t)count, sizeof(*requests), - repo_stable_node_request_compare); -} - -int cbm_zova_repository_find_nodes_by_stable_ids(cbm_zova_repository_t *repo, - const char *workspace_id, - const char *const *stable_ids, - int count, cbm_node_t **out) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || count < 0 || !out || - (count > 0 && !stable_ids)) return CBM_STORE_ERR; - *out = NULL; - if (count == 0) return CBM_STORE_OK; - repo_stable_node_request_t *requests = calloc((size_t)count, sizeof(*requests)); - int64_t *keys = calloc((size_t)count, sizeof(*keys)); - cbm_node_t *nodes = calloc((size_t)count, sizeof(*nodes)); - if (!requests || !keys || !nodes) goto error; - for (int i = 0; i < count; i++) { - if (!stable_ids[i] || !stable_ids[i][0]) goto error; - requests[i] = (repo_stable_node_request_t){ - .stable_id = stable_ids[i], .input_index = i}; - } - if (count > 1) qsort(requests, (size_t)count, sizeof(*requests), - repo_stable_node_request_compare); - char graph_name[REPO_GRAPH_NAME_CAP]; - if (repo_graph_name(repo, graph_name, sizeof(graph_name)) != 0) goto error; - zova_graph_scan_cursor cursor = {0}; - for (;;) { - zova_graph_scan_results page = {0}; - zova_status status = zova_graph_scan(&(zova_graph_scan_request){ - .db = repo->db, .graph_name = graph_name, .node_after = cursor, - .node_limit = REPO_GRAPH_SCAN_PAGE, .edge_limit = 0, .out_results = &page}); - if (status != ZOVA_OK) { zova_graph_scan_results_free(&page); goto error; } - for (size_t i = 0; i < page.nodes_len; i++) { - repo_stable_node_request_t *request = repo_stable_node_request_find( - requests, count, page.nodes[i].node_id); - if (request) request->node_key = page.nodes[i].node_key; - } - if (page.nodes_len) { - const zova_graph_scan_node *last = &page.nodes[page.nodes_len - 1]; - cursor = (zova_graph_scan_cursor){.created_order = last->created_order, - .key = last->node_key}; - } - uint8_t more = page.has_more_nodes; - zova_graph_scan_results_free(&page); - if (!more) break; - } - for (int i = 0; i < count; i++) { - if (requests[i].node_key <= 0) goto not_found; - keys[requests[i].input_index] = requests[i].node_key; - } - zova_graph_keyed_node_results native = {0}; - if (repo_native_nodes_by_key(repo, keys, (size_t)count, &native) != 0) goto error; - zova_statement *statement = NULL; - int rc = repo_prepare(repo, - "SELECT n.name,n.qualified_name,f.file_path,n.start_line,n.end_line,n.properties " - "FROM cbm_nodes_v1 n JOIN cbm_files_v1 f ON f.file_key=n.file_key " - "WHERE n.workspace_key=?1 AND n.zova_node_key=?2", &statement); - for (int i = 0; rc == 0 && i < count; i++) { - if (!native.items[i].found || native.items[i].node_key != keys[i] || - !native.items[i].node_id || !native.items[i].kind || - strcmp(native.items[i].node_id, stable_ids[i]) != 0) { rc = -1; break; } - if (zova_statement_reset(statement) != ZOVA_OK || - zova_statement_clear_bindings(statement) != ZOVA_OK || - repo_bind_i64(statement, 1, repo->workspace_key) != 0 || - repo_bind_i64(statement, 2, keys[i]) != 0) { rc = -1; break; } - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(statement, &step) != 0 || step != ZOVA_STEP_ROW) { rc = -1; break; } - cbm_node_t *node = &nodes[i]; - node->id = stable_numeric_id(native.items[i].node_id); - node->project = repo_dup((const uint8_t *)repo->project, strlen(repo->project)); - node->label = repo_dup((const uint8_t *)native.items[i].kind, - native.items[i].kind_len); - node->name = repo_column_text(statement, 0); - node->qualified_name = repo_column_text(statement, 1); - node->file_path = repo_column_text(statement, 2); - node->start_line = (int)repo_column_i64(statement, 3); - node->end_line = (int)repo_column_i64(statement, 4); - node->properties_json = repo_column_text(statement, 5); - if (!node->project || !node->label || !node->name || !node->qualified_name || - !node->file_path || !node->properties_json) rc = -1; - } - if (statement) (void)zova_statement_finalize(statement); - zova_graph_keyed_node_results_free(&native); - if (rc != 0) goto error; - free(requests); free(keys); - *out = nodes; - return CBM_STORE_OK; -not_found: - free(requests); free(keys); free(nodes); - return CBM_STORE_NOT_FOUND; -error: - free(requests); free(keys); - cbm_store_free_nodes(nodes, count); - return CBM_STORE_ERR; -} - -int cbm_zova_repository_find_nodes_by_keys(cbm_zova_repository_t *repo, - const char *workspace_id, - const int64_t *node_keys, - int count, cbm_node_t **out) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || count < 0 || !out || - (count > 0 && !node_keys)) return CBM_STORE_ERR; - *out = NULL; - if (count == 0) return CBM_STORE_OK; - cbm_node_t *nodes = calloc((size_t)count, sizeof(*nodes)); - if (!nodes) return CBM_STORE_ERR; - for (int i = 0; i < count; i++) if (node_keys[i] <= 0) goto error; - zova_graph_keyed_node_results native = {0}; - if (repo_native_nodes_by_key(repo, node_keys, (size_t)count, &native) != 0) goto error; - zova_statement *statement = NULL; - int rc = repo_prepare(repo, - "SELECT n.name,n.qualified_name,f.file_path,n.start_line,n.end_line,n.properties " - "FROM cbm_nodes_v1 n JOIN cbm_files_v1 f ON f.file_key=n.file_key " - "WHERE n.workspace_key=?1 AND n.zova_node_key=?2", &statement); - for (int i = 0; rc == 0 && i < count; i++) { - if (!native.items[i].found || native.items[i].node_key != node_keys[i] || - !native.items[i].node_id || !native.items[i].kind || - zova_statement_reset(statement) != ZOVA_OK || - zova_statement_clear_bindings(statement) != ZOVA_OK || - repo_bind_i64(statement, 1, repo->workspace_key) != 0 || - repo_bind_i64(statement, 2, node_keys[i]) != 0) { rc = -1; break; } - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(statement, &step) != 0 || step != ZOVA_STEP_ROW) { rc = -1; break; } - cbm_node_t *node = &nodes[i]; - node->id = stable_numeric_id(native.items[i].node_id); - node->project = repo_dup((const uint8_t *)repo->project, strlen(repo->project)); - node->label = repo_dup((const uint8_t *)native.items[i].kind, - native.items[i].kind_len); - node->name = repo_column_text(statement, 0); - node->qualified_name = repo_column_text(statement, 1); - node->file_path = repo_column_text(statement, 2); - node->start_line = (int)repo_column_i64(statement, 3); - node->end_line = (int)repo_column_i64(statement, 4); - node->properties_json = repo_column_text(statement, 5); - if (!node->project || !node->label || !node->name || !node->qualified_name || - !node->file_path || !node->properties_json) rc = -1; - } - if (statement) (void)zova_statement_finalize(statement); - zova_graph_keyed_node_results_free(&native); - if (rc != 0) goto error; - *out = nodes; - return CBM_STORE_OK; -error: - cbm_store_free_nodes(nodes, count); - return CBM_STORE_ERR; -} - -int cbm_zova_repository_stable_id_for_numeric_id(cbm_zova_repository_t *repo, - const char *workspace_id, - int64_t numeric_id, char *out, - size_t out_size) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || !out || out_size == 0) - return CBM_STORE_ERR; - out[0] = '\0'; - const repo_numeric_id_entry_t *entry = repo_numeric_ids_find(repo, numeric_id); - if (!entry) return repo->numeric_id_strings ? CBM_STORE_NOT_FOUND : CBM_STORE_ERR; - int written = snprintf(out, out_size, "%s", entry->stable_id); - return written >= 0 && (size_t)written < out_size ? CBM_STORE_OK : CBM_STORE_ERR; -} - -int cbm_zova_repository_find_node_by_numeric_id(cbm_zova_repository_t *repo, - const char *workspace_id, - int64_t numeric_id, cbm_node_t *out) { - char stable[CBM_ZOVA_WORKSPACE_ID_MAX + 96]; - int rc = cbm_zova_repository_stable_id_for_numeric_id( - repo, workspace_id, numeric_id, stable, sizeof(stable)); - return rc == CBM_STORE_OK - ? cbm_zova_repository_find_node_by_stable_id(repo, workspace_id, stable, out) - : rc; -} - -int cbm_zova_repository_find_nodes_by_file_overlap(cbm_zova_repository_t *repo, - const char *workspace_id, - const char *file_path, - int start_line, int end_line, - cbm_node_t **out, int *out_count) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || !file_path || - !out || !out_count) return CBM_STORE_ERR; - *out = NULL; - *out_count = 0; - zova_statement *stmt = NULL; - const char *sql = - "SELECT n.zova_node_key,n.name,n.qualified_name,f.file_path,n.start_line,n.end_line," - "n.properties FROM cbm_nodes_v1 n JOIN cbm_files_v1 f ON f.file_key=n.file_key " - "WHERE n.workspace_key=?1 AND f.file_path=?2 " - "AND n.label NOT IN ('Module','Package','File','Folder') " - "AND n.start_line<=?3 AND n.end_line>=?4 " - "ORDER BY n.start_line,n.qualified_name,n.zova_node_key"; - int rc = repo_prepare(repo, sql, &stmt); - if (rc == 0) rc = repo_bind_i64(stmt, 1, repo->workspace_key); - if (rc == 0) rc = repo_bind_text(stmt, 2, file_path); - if (rc == 0) rc = repo_bind_i64(stmt, 3, end_line); - if (rc == 0) rc = repo_bind_i64(stmt, 4, start_line); - int cap = 8, count = 0; - cbm_node_t *nodes = rc == 0 ? calloc((size_t)cap, sizeof(*nodes)) : NULL; - int64_t *keys = rc == 0 ? calloc((size_t)cap, sizeof(*keys)) : NULL; - if (rc != 0 || !nodes || !keys) goto error; - for (;;) { - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(stmt, &step) != 0) goto error; - if (step == ZOVA_STEP_DONE) break; - if (count == cap) { - int old_cap = cap; - cap *= 2; - cbm_node_t *grown_nodes = realloc(nodes, (size_t)cap * sizeof(*grown_nodes)); - int64_t *grown_keys = realloc(keys, (size_t)cap * sizeof(*grown_keys)); - if (!grown_nodes || !grown_keys) { - if (grown_nodes) nodes = grown_nodes; - if (grown_keys) keys = grown_keys; - goto error; - } - nodes = grown_nodes; - keys = grown_keys; - memset(nodes + old_cap, 0, (size_t)(cap - old_cap) * sizeof(*nodes)); - } - keys[count] = repo_column_i64(stmt, 0); - nodes[count].project = repo_dup((const uint8_t *)repo->project, strlen(repo->project)); - nodes[count].name = repo_column_text(stmt, 1); - nodes[count].qualified_name = repo_column_text(stmt, 2); - nodes[count].file_path = repo_column_text(stmt, 3); - nodes[count].start_line = (int)repo_column_i64(stmt, 4); - nodes[count].end_line = (int)repo_column_i64(stmt, 5); - nodes[count].properties_json = repo_column_text(stmt, 6); - count++; - } - (void)zova_statement_finalize(stmt); - stmt = NULL; - zova_graph_keyed_node_results native = {0}; - if (count && repo_native_nodes_by_key(repo, keys, (size_t)count, &native) != 0) - goto error; - for (int i = 0; i < count; i++) { - if (!native.items[i].found || !native.items[i].node_id || !native.items[i].kind) { - zova_graph_keyed_node_results_free(&native); - goto error; - } - nodes[i].id = stable_numeric_id(native.items[i].node_id); - nodes[i].label = repo_dup((const uint8_t *)native.items[i].kind, - native.items[i].kind_len); - } - zova_graph_keyed_node_results_free(&native); - free(keys); - *out = nodes; - *out_count = count; - return CBM_STORE_OK; -error: - if (stmt) (void)zova_statement_finalize(stmt); - free(keys); - cbm_store_free_nodes(nodes, count); - return CBM_STORE_ERR; -} - -int cbm_zova_repository_get_project(cbm_zova_repository_t *repo, const char *workspace_id, - cbm_project_t *out) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || !out) - return CBM_STORE_ERR; - zova_statement *stmt = NULL; - if (repo_prepare(repo, "SELECT project,indexed_at,root_path FROM cbm_projects_v1 " - "WHERE workspace_key=?1", &stmt) != 0 || - repo_bind_i64(stmt, 1, repo->workspace_key) != 0) goto error; - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(stmt, &step) != 0 || step != ZOVA_STEP_ROW) goto error; - memset(out, 0, sizeof(*out)); - out->name = repo_column_text(stmt, 0); - out->indexed_at = repo_column_text(stmt, 1); - out->root_path = repo_column_text(stmt, 2); - (void)zova_statement_finalize(stmt); - return CBM_STORE_OK; -error: - if (stmt) (void)zova_statement_finalize(stmt); - return CBM_STORE_ERR; -} - -int cbm_zova_repository_index_status(cbm_zova_repository_t *repo, const char *workspace_id, - int64_t *generation, - char **indexed_at) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || !generation || !indexed_at) - return CBM_STORE_ERR; - cbm_project_t project = {0}; - if (cbm_zova_repository_get_project(repo, workspace_id, &project) != CBM_STORE_OK) - return CBM_STORE_ERR; - *generation = repo->generation; - *indexed_at = (char *)project.indexed_at; - free((void *)project.name); - free((void *)project.root_path); - return CBM_STORE_OK; -} - -int cbm_zova_repository_counts(cbm_zova_repository_t *repo, const char *workspace_id, - int *nodes, int *edges) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || !nodes || !edges) - return CBM_STORE_ERR; - zova_statement *stmt = NULL; - if (repo_prepare(repo, - "SELECT (SELECT count(*) FROM cbm_nodes_v1 WHERE workspace_key=?1)," - "metadata_edges FROM cbm_generation_integrity_v2 " - "WHERE workspace_key=?1 AND generation=?2", - &stmt) != 0 || - repo_bind_i64(stmt, 1, repo->workspace_key) != 0 || - repo_bind_i64(stmt, 2, repo->generation) != 0) goto error; - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(stmt, &step) != 0 || step != ZOVA_STEP_ROW) goto error; - *nodes = (int)repo_column_i64(stmt, 0); - *edges = (int)repo_column_i64(stmt, 1); - (void)zova_statement_finalize(stmt); - return CBM_STORE_OK; -error: - if (stmt) (void)zova_statement_finalize(stmt); - return CBM_STORE_ERR; -} - -int cbm_zova_repository_file_coverage(cbm_zova_repository_t *repo, const char *workspace_id, - const char *exact_path, cbm_zova_file_coverage_t **out, - int *count, char **out_root_path, int64_t *out_generation, - bool *out_recorded) { - if (!out || !count) return CBM_STORE_ERR; - *out = NULL; - *count = 0; - if (out_root_path) *out_root_path = NULL; - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK) return CBM_STORE_ERR; - if (out_generation) *out_generation = repo->generation; - zova_statement *stmt = NULL; - cbm_zova_file_coverage_t *rows = NULL; - int n = 0; - int cap = 0; - if (out_recorded) { - /* Generations indexed before coverage stamps existed carry no marker. */ - *out_recorded = false; - if (repo_prepare(repo, - "SELECT properties FROM cbm_nodes_v1 WHERE workspace_key=?1 " - "AND label='Project'", - &stmt) != 0 || - repo_bind_i64(stmt, 1, repo->workspace_key) != 0) goto error; - for (;;) { - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(stmt, &step) != 0) goto error; - if (step == ZOVA_STEP_DONE) break; - char *props = repo_column_text(stmt, 0); - if (props && strstr(props, "\"coverage_version\":")) *out_recorded = true; - free(props); - } - (void)zova_statement_finalize(stmt); - stmt = NULL; - } - if (out_root_path) { - if (repo_prepare(repo, "SELECT root_path FROM cbm_projects_v1 WHERE workspace_key=?1", - &stmt) != 0 || - repo_bind_i64(stmt, 1, repo->workspace_key) != 0) goto error; - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(stmt, &step) != 0 || step != ZOVA_STEP_ROW) goto error; - *out_root_path = repo_column_text(stmt, 0); - (void)zova_statement_finalize(stmt); - stmt = NULL; - if (!*out_root_path) goto error; - } - if (repo_prepare(repo, - "SELECT f.file_path,n.properties,n.zova_node_key IS NOT NULL," - "h.file_key IS NOT NULL,COALESCE(h.mtime_ns,0),COALESCE(h.size_bytes,0) " - "FROM cbm_files_v1 f " - "LEFT JOIN cbm_nodes_v1 n ON n.workspace_key=f.workspace_key " - "AND n.file_key=f.file_key AND n.label='File' " - "LEFT JOIN cbm_file_hashes_v1 h ON h.file_key=f.file_key " - "WHERE f.workspace_key=?1 AND (?2 IS NULL OR f.file_path=?2) " - "AND (n.zova_node_key IS NOT NULL OR h.file_key IS NOT NULL) " - "ORDER BY f.file_path", - &stmt) != 0 || - repo_bind_i64(stmt, 1, repo->workspace_key) != 0 || - (exact_path && repo_bind_text(stmt, 2, exact_path) != 0)) goto error; - for (;;) { - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(stmt, &step) != 0) goto error; - if (step == ZOVA_STEP_DONE) break; - if (n == cap) { - if (cap > INT_MAX / 2) goto error; - int next = cap ? cap * 2 : 64; - cbm_zova_file_coverage_t *grown = realloc(rows, (size_t)next * sizeof(*rows)); - if (!grown) goto error; - rows = grown; - cap = next; - } - cbm_zova_file_coverage_t *row = &rows[n]; - memset(row, 0, sizeof(*row)); - row->file_path = repo_column_text(stmt, 0); - bool has_node = repo_column_i64(stmt, 2) != 0; - row->properties = has_node ? repo_column_text(stmt, 1) : NULL; - row->has_hash = repo_column_i64(stmt, 3) != 0; - row->mtime_ns = repo_column_i64(stmt, 4); - row->size_bytes = repo_column_i64(stmt, 5); - n++; - if (!row->file_path || (has_node && !row->properties)) goto error; - } - (void)zova_statement_finalize(stmt); - *out = rows; - *count = n; - return CBM_STORE_OK; -error: - if (stmt) (void)zova_statement_finalize(stmt); - cbm_zova_file_coverage_free(rows, n); - if (out_root_path) { - free(*out_root_path); - *out_root_path = NULL; - } - return CBM_STORE_ERR; -} - -void cbm_zova_file_coverage_free(cbm_zova_file_coverage_t *rows, int count) { - if (!rows) return; - for (int i = 0; i < count; i++) { - free(rows[i].file_path); - free(rows[i].properties); - } - free(rows); -} - -int cbm_zova_repository_project_summary(cbm_zova_repository_t *repo, - const char *workspace_id, - cbm_project_summary_export_t *out) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || !out) - return CBM_STORE_ERR; - memset(out, 0, sizeof(*out)); - zova_statement *stmt = NULL; - if (repo_prepare(repo, "SELECT summary,source_hash,created_at,updated_at " - "FROM cbm_project_summaries_v2 WHERE workspace_key=?1", &stmt) != 0 || - repo_bind_i64(stmt, 1, repo->workspace_key) != 0) goto error; - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(stmt, &step) != 0) goto error; - if (step == ZOVA_STEP_DONE) { (void)zova_statement_finalize(stmt); return CBM_STORE_NOT_FOUND; } - out->summary=repo_column_text(stmt,0); out->source_hash=repo_column_text(stmt,1); - out->created_at=repo_column_text(stmt,2); out->updated_at=repo_column_text(stmt,3); - (void)zova_statement_finalize(stmt); - return CBM_STORE_OK; -error: - if(stmt)(void)zova_statement_finalize(stmt); - cbm_store_project_summary_export_free(out); - return CBM_STORE_ERR; -} - -typedef struct { - cbm_zova_repository_t *repo; - const char *source; - const char *target; - const char *edge_type; - size_t edge_type_len; - cbm_edge_t **edges; - int *count; - int *capacity; -} repo_payload_edge_context_t; - -static int repo_append_payload_edge( - const cbm_zova_edge_payload_record_t *record, void *context_ptr) { - repo_payload_edge_context_t *context = context_ptr; - if (!record || !context || !context->repo || !context->edges || - !context->count || !context->capacity) return -1; - if (*context->count == *context->capacity) { - if (*context->capacity > INT_MAX / 2) return -1; - int grown_capacity = *context->capacity * 2; - cbm_edge_t *grown = realloc( - *context->edges, (size_t)grown_capacity * sizeof(*grown)); - if (!grown) return -1; - memset(grown + *context->capacity, 0, - (size_t)(grown_capacity - *context->capacity) * sizeof(*grown)); - *context->edges = grown; - *context->capacity = grown_capacity; - } - char local_name[CBM_ZOVA_WORKSPACE_ID_MAX + 80]; - if (record->local_name.len >= sizeof(local_name)) return -1; - memcpy(local_name, record->local_name.data, record->local_name.len); - local_name[record->local_name.len] = '\0'; - char edge_id[CBM_ZOVA_WORKSPACE_ID_MAX + 80]; - if (repo_edge_id(context->repo->workspace_id, context->source, - context->edge_type, context->target, local_name, - edge_id, sizeof(edge_id)) != 0) return -1; - cbm_edge_t *edge = &(*context->edges)[*context->count]; - char *project = repo_dup((const uint8_t *)context->repo->project, - strlen(context->repo->project)); - char *type = repo_dup((const uint8_t *)context->edge_type, - context->edge_type_len); - char *properties = repo_dup(record->properties.data, - record->properties.len); - if (!project || !type || !properties) { - free(project); - free(type); - free(properties); - return -1; - } - *edge = (cbm_edge_t){ - .id = stable_numeric_id(edge_id), - .project = project, - .source_id = stable_numeric_id(context->source), - .target_id = stable_numeric_id(context->target), - .type = type, - .properties_json = properties, - }; - (*context->count)++; - return 0; -} - -int cbm_zova_repository_call_neighbor_keys(cbm_zova_repository_t *repo, const char *workspace_id, - const char *stable_id, bool inbound, int64_t **out, - int *out_count) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || !stable_id || !out || - !out_count) return CBM_STORE_ERR; - *out = NULL; *out_count = 0; - char graph_name[REPO_GRAPH_NAME_CAP]; - if (repo_graph_name(repo, graph_name, sizeof(graph_name)) != 0) return CBM_STORE_ERR; - zova_graph_keyed_neighbor_results neighbors = {0}; - if (zova_graph_neighbors_keyed(&(zova_graph_neighbors_keyed_request){ - .db=repo->db,.graph_name=graph_name,.node_id=stable_id, - .direction=inbound ? ZOVA_GRAPH_NEIGHBOR_INCOMING : ZOVA_GRAPH_NEIGHBOR_OUTGOING, - .edge_type=NULL,.limit=(size_t)INT64_MAX,.out_results=&neighbors}) != ZOVA_OK) { - zova_graph_keyed_neighbor_results_free(&neighbors); - return CBM_STORE_ERR; - } - int64_t *keys = neighbors.len ? calloc(neighbors.len, sizeof(*keys)) : NULL; - if (neighbors.len && !keys) { - zova_graph_keyed_neighbor_results_free(&neighbors); - return CBM_STORE_ERR; - } - int count = 0; - for (size_t i = 0; i < neighbors.len; i++) { - const char *type = neighbors.items[i].edge_type; - size_t len = neighbors.items[i].edge_type_len; - if (type && ((len == 5 && memcmp(type, "CALLS", 5) == 0) || - (len == 10 && memcmp(type, "HTTP_CALLS", 10) == 0) || - (len == 11 && memcmp(type, "ASYNC_CALLS", 11) == 0))) - keys[count++] = neighbors.items[i].neighbor_node_key; - } - zova_graph_keyed_neighbor_results_free(&neighbors); - *out = keys; *out_count = count; - return CBM_STORE_OK; -} - -int cbm_zova_repository_find_edges(cbm_zova_repository_t *repo, const char *workspace_id, - const char *stable_id, - const char *direction, cbm_edge_t **out, int *out_count) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || !stable_id || !out || - !out_count) return CBM_STORE_ERR; - *out = NULL; *out_count = 0; - char graph_name[REPO_GRAPH_NAME_CAP]; - if (repo_graph_name(repo, graph_name, sizeof(graph_name)) != 0) return CBM_STORE_ERR; - int directions[2]; - int direction_count = 1; - if (!direction || strcmp(direction, "any") == 0) { - directions[0] = ZOVA_GRAPH_NEIGHBOR_OUTGOING; - directions[1] = ZOVA_GRAPH_NEIGHBOR_INCOMING; - direction_count = 2; - } else directions[0] = strcmp(direction, "inbound") == 0 - ? ZOVA_GRAPH_NEIGHBOR_INCOMING : ZOVA_GRAPH_NEIGHBOR_OUTGOING; - int cap = 8, count = 0; - cbm_edge_t *edges = calloc((size_t)cap, sizeof(*edges)); - if (!edges) return CBM_STORE_ERR; - for (int d = 0; d < direction_count; d++) { - zova_graph_keyed_neighbor_results neighbors = {0}; - if (zova_graph_neighbors_keyed(&(zova_graph_neighbors_keyed_request){ - .db=repo->db,.graph_name=graph_name,.node_id=stable_id, - .direction=directions[d],.edge_type=NULL,.limit=(size_t)INT64_MAX, - .out_results=&neighbors}) != ZOVA_OK) { - zova_graph_keyed_neighbor_results_free(&neighbors); - cbm_store_free_edges(edges,count); return CBM_STORE_ERR; - } - int64_t *edge_keys = neighbors.len - ? calloc(neighbors.len, sizeof(*edge_keys)) : NULL; - zova_graph_edge_payload_results payloads = {0}; - if (neighbors.len && !edge_keys) { - zova_graph_keyed_neighbor_results_free(&neighbors); - cbm_store_free_edges(edges, count); - return CBM_STORE_ERR; - } - for (size_t i = 0; i < neighbors.len; i++) - edge_keys[i] = neighbors.items[i].edge_key; - if (neighbors.len && - zova_graph_edge_payload_get_many( - &(zova_graph_edge_payload_get_many_request){ - .db = repo->db, - .graph_name = graph_name, - .edge_keys = edge_keys, - .key_count = neighbors.len, - .out_results = &payloads, - }) != ZOVA_OK) { - free(edge_keys); - zova_graph_keyed_neighbor_results_free(&neighbors); - cbm_store_free_edges(edges, count); - return CBM_STORE_ERR; - } - free(edge_keys); - if (payloads.len != neighbors.len) { - zova_graph_edge_payload_results_free(&payloads); - zova_graph_keyed_neighbor_results_free(&neighbors); - cbm_store_free_edges(edges, count); - return CBM_STORE_ERR; - } - for (size_t i = 0; i < neighbors.len; i++) { - if (direction_count == 2 && d == 1 && - strcmp(neighbors.items[i].node_id, stable_id) == 0) continue; - if (!payloads.items[i].found || - payloads.items[i].edge_key != neighbors.items[i].edge_key) { - zova_graph_edge_payload_results_free(&payloads); - zova_graph_keyed_neighbor_results_free(&neighbors); - cbm_store_free_edges(edges, count); - return CBM_STORE_ERR; - } - repo_payload_edge_context_t context = { - .repo = repo, - .source = directions[d] == ZOVA_GRAPH_NEIGHBOR_OUTGOING - ? stable_id : neighbors.items[i].node_id, - .target = directions[d] == ZOVA_GRAPH_NEIGHBOR_OUTGOING - ? neighbors.items[i].node_id : stable_id, - .edge_type = neighbors.items[i].edge_type, - .edge_type_len = neighbors.items[i].edge_type_len, - .edges = &edges, - .count = &count, - .capacity = &cap, - }; - if (cbm_zova_edge_payload_visit( - payloads.items[i].payload, payloads.items[i].payload_len, - repo_append_payload_edge, &context, NULL) != 0) { - zova_graph_edge_payload_results_free(&payloads); - zova_graph_keyed_neighbor_results_free(&neighbors); - cbm_store_free_edges(edges, count); - return CBM_STORE_ERR; - } - } - zova_graph_edge_payload_results_free(&payloads); - zova_graph_keyed_neighbor_results_free(&neighbors); - } - *out=edges;*out_count=count;return CBM_STORE_OK; -} - -typedef int (*repo_edge_record_visitor_t)(const zova_graph_scan_edge *native, - const cbm_zova_edge_payload_record_t *record, - void *context); - -typedef struct { - const zova_graph_scan_edge *native; - repo_edge_record_visitor_t visitor; - void *context; -} repo_scan_record_context_t; - -static int repo_scan_payload_record(const cbm_zova_edge_payload_record_t *record, - void *context_ptr) { - const repo_scan_record_context_t *context = context_ptr; - return context->visitor(context->native, record, context->context); -} - -static bool repo_edge_type_matches(const zova_graph_scan_edge *edge, const char *edge_type) { - return !edge_type || (edge->edge_type && edge->edge_type_len == strlen(edge_type) && - memcmp(edge->edge_type, edge_type, edge->edge_type_len) == 0); -} - -/* Visit every cbm edge record of the workspace graph in scan order, or only - * those of edge_type. Payloads are read only for matching graph edges. */ -static int repo_scan_edges(cbm_zova_repository_t *repo, const char *edge_type, - repo_edge_record_visitor_t visitor, void *context) { - char graph_name[REPO_GRAPH_NAME_CAP]; - if (repo_graph_name(repo, graph_name, sizeof(graph_name)) != 0) return CBM_STORE_ERR; - zova_graph_scan_cursor cursor = {0}; - int64_t edge_keys[REPO_EDGE_SCAN_PAGE]; - const zova_graph_scan_edge *matched[REPO_EDGE_SCAN_PAGE]; - for (;;) { - zova_graph_scan_results page = {0}; - zova_graph_edge_payload_results payloads = {0}; - size_t match_count = 0; - int ok = zova_graph_scan(&(zova_graph_scan_request){ - .db = repo->db, .graph_name = graph_name, .edge_after = cursor, - .node_limit = 0, .edge_limit = REPO_EDGE_SCAN_PAGE, - .out_results = &page}) == ZOVA_OK && - page.edges_len <= REPO_EDGE_SCAN_PAGE; - for (size_t i = 0; ok && i < page.edges_len; i++) { - if (!repo_edge_type_matches(&page.edges[i], edge_type)) continue; - matched[match_count] = &page.edges[i]; - edge_keys[match_count++] = page.edges[i].edge_key; - } - ok = ok && (match_count == 0 || - (zova_graph_edge_payload_get_many(&(zova_graph_edge_payload_get_many_request){ - .db = repo->db, .graph_name = graph_name, .edge_keys = edge_keys, - .key_count = match_count, .out_results = &payloads}) == ZOVA_OK && - payloads.len == match_count)); - for (size_t i = 0; ok && i < match_count; i++) { - repo_scan_record_context_t edge = {matched[i], visitor, context}; - ok = payloads.items[i].found && payloads.items[i].edge_key == matched[i]->edge_key && - cbm_zova_edge_payload_visit(payloads.items[i].payload, - payloads.items[i].payload_len, - repo_scan_payload_record, &edge, NULL) == 0; - } - if (page.edges_len) { - const zova_graph_scan_edge *last = &page.edges[page.edges_len - 1]; - cursor = (zova_graph_scan_cursor){.created_order = last->created_order, - .key = last->edge_key}; - } - uint8_t more = page.has_more_edges; - zova_graph_edge_payload_results_free(&payloads); - zova_graph_scan_results_free(&page); - if (!ok) return CBM_STORE_ERR; - if (!more) return CBM_STORE_OK; - } -} - -typedef struct { - cbm_zova_repository_edge_visitor_t visitor; - void *context; -} repo_visit_edge_context_t; - -static int repo_visit_edge_record(const zova_graph_scan_edge *native, - const cbm_zova_edge_payload_record_t *record, - void *context_ptr) { - const repo_visit_edge_context_t *context = context_ptr; - return context->visitor(native->source_node_key, native->target_node_key, native->edge_type, - native->edge_type_len, record->properties.data, - record->properties.len, context->context); -} - -int cbm_zova_repository_visit_edges(cbm_zova_repository_t *repo, const char *workspace_id, - const char *edge_type, - cbm_zova_repository_edge_visitor_t visitor, void *context) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || !visitor) { - return CBM_STORE_ERR; - } - repo_visit_edge_context_t visit = {visitor, context}; - return repo_scan_edges(repo, edge_type, repo_visit_edge_record, &visit); -} - -typedef struct { - int64_t source_key; - int64_t target_key; - char *local_name; - char *properties; -} repo_typed_edge_t; - -typedef struct { - repo_typed_edge_t *items; - int count; - int capacity; -} repo_typed_edges_t; - -static int repo_collect_typed_edge(const zova_graph_scan_edge *native, - const cbm_zova_edge_payload_record_t *record, - void *context_ptr) { - repo_typed_edges_t *edges = context_ptr; - if (edges->count == edges->capacity) { - int capacity = edges->capacity ? edges->capacity * 2 : 64; - repo_typed_edge_t *grown = realloc(edges->items, (size_t)capacity * sizeof(*grown)); - if (!grown) return -1; - edges->items = grown; - edges->capacity = capacity; - } - repo_typed_edge_t *edge = &edges->items[edges->count]; - edge->source_key = native->source_node_key; - edge->target_key = native->target_node_key; - edge->local_name = repo_dup(record->local_name.data, record->local_name.len); - edge->properties = repo_dup(record->properties.data, record->properties.len); - if (!edge->local_name || !edge->properties) { - free(edge->local_name); - free(edge->properties); - return -1; - } - edges->count++; - return 0; -} - -static int repo_int64_compare(const void *left_ptr, const void *right_ptr) { - int64_t left = *(const int64_t *)left_ptr; - int64_t right = *(const int64_t *)right_ptr; - return (left > right) - (left < right); -} - -/* Every cbm edge of one type, in scan order, with the ids the other reads - * use: endpoints and edge ids derive from stable ids, as in find_edges. */ -int cbm_zova_repository_find_edges_by_type(cbm_zova_repository_t *repo, const char *workspace_id, - const char *edge_type, cbm_edge_t **out, - int *out_count) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || !edge_type || !out || - !out_count) return CBM_STORE_ERR; - *out = NULL; - *out_count = 0; - repo_typed_edges_t found = {0}; - int64_t *keys = NULL; - zova_graph_keyed_node_results native = {0}; - cbm_edge_t *edges = NULL; - int rc = repo_scan_edges(repo, edge_type, repo_collect_typed_edge, &found); - size_t key_count = 0; - if (rc == CBM_STORE_OK && found.count > 0) { - keys = malloc((size_t)found.count * 2 * sizeof(*keys)); - edges = calloc((size_t)found.count, sizeof(*edges)); - if (!keys || !edges) rc = CBM_STORE_ERR; - } - if (rc == CBM_STORE_OK && found.count > 0) { - for (int i = 0; i < found.count; i++) { - keys[key_count++] = found.items[i].source_key; - keys[key_count++] = found.items[i].target_key; - } - qsort(keys, key_count, sizeof(*keys), repo_int64_compare); - size_t unique = 0; - for (size_t i = 0; i < key_count; i++) - if (unique == 0 || keys[unique - 1] != keys[i]) keys[unique++] = keys[i]; - key_count = unique; - if (repo_native_nodes_by_key(repo, keys, key_count, &native) != 0) rc = CBM_STORE_ERR; - } - for (int i = 0; rc == CBM_STORE_OK && i < found.count; i++) { - const int64_t *source = bsearch(&found.items[i].source_key, keys, key_count, - sizeof(*keys), repo_int64_compare); - const int64_t *target = bsearch(&found.items[i].target_key, keys, key_count, - sizeof(*keys), repo_int64_compare); - const char *source_id = source ? native.items[source - keys].node_id : NULL; - const char *target_id = target ? native.items[target - keys].node_id : NULL; - char edge_id[CBM_ZOVA_WORKSPACE_ID_MAX + 80]; - if (!source_id || !target_id || !native.items[source - keys].found || - !native.items[target - keys].found || - repo_edge_id(repo->workspace_id, source_id, edge_type, target_id, - found.items[i].local_name, edge_id, sizeof(edge_id)) != 0) { - rc = CBM_STORE_ERR; - break; - } - edges[i] = (cbm_edge_t){ - .id = stable_numeric_id(edge_id), - .project = repo_dup((const uint8_t *)repo->project, strlen(repo->project)), - .source_id = stable_numeric_id(source_id), - .target_id = stable_numeric_id(target_id), - .type = repo_dup((const uint8_t *)edge_type, strlen(edge_type)), - .properties_json = found.items[i].properties, - }; - found.items[i].properties = NULL; - if (!edges[i].project || !edges[i].type) rc = CBM_STORE_ERR; - } - for (int i = 0; i < found.count; i++) { - free(found.items[i].local_name); - free(found.items[i].properties); - } - free(found.items); - free(keys); - zova_graph_keyed_node_results_free(&native); - if (rc != CBM_STORE_OK) { - cbm_store_free_edges(edges, found.count); - return CBM_STORE_ERR; - } - *out = edges; - *out_count = found.count; - return CBM_STORE_OK; -} - -typedef struct { - cbm_zova_identity_edge_t *items; - int count; - int capacity; - uint64_t max_edges; - bool over_limit; -} repo_identity_edges_t; - -static int repo_collect_identity_edge(const zova_graph_scan_edge *native, - const cbm_zova_edge_payload_record_t *record, - void *context_ptr) { - repo_identity_edges_t *edges = context_ptr; - if ((uint64_t)edges->count >= edges->max_edges) { - edges->over_limit = true; - return -1; - } - if (edges->count == edges->capacity) { - int capacity = edges->capacity ? edges->capacity * 2 : 64; - cbm_zova_identity_edge_t *grown = - realloc(edges->items, (size_t)capacity * sizeof(*grown)); - if (!grown) return -1; - edges->items = grown; - edges->capacity = capacity; - } - cbm_zova_identity_edge_t *edge = &edges->items[edges->count]; - edge->source_key = native->source_node_key; - edge->target_key = native->target_node_key; - edge->type = repo_dup((const uint8_t *)native->edge_type, native->edge_type_len); - edge->local_name = repo_dup(record->local_name.data, record->local_name.len); - if (!edge->type || !edge->local_name) { - free(edge->type); - free(edge->local_name); - return -1; - } - edges->count++; - return 0; -} - -int cbm_zova_repository_graph_identities(cbm_zova_repository_t *repo, const char *workspace_id, - uint64_t max_nodes, uint64_t max_edges, - cbm_zova_identity_node_t **out_nodes, int *node_count, - cbm_zova_identity_edge_t **out_edges, int *edge_count) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || !out_nodes || - !node_count || !out_edges || !edge_count) return CBM_STORE_ERR; - *out_nodes = NULL; - *node_count = 0; - *out_edges = NULL; - *edge_count = 0; - cbm_zova_identity_node_t *nodes = NULL; - int count = 0; - int capacity = 0; - int rc = CBM_STORE_OK; - zova_statement *stmt = NULL; - if (repo_prepare(repo, - "SELECT n.zova_node_key,n.qualified_name,n.label,f.file_path " - "FROM cbm_nodes_v1 n JOIN cbm_files_v1 f ON f.file_key=n.file_key " - "WHERE n.workspace_key=?1 ORDER BY n.zova_node_key", - &stmt) != 0 || - repo_bind_i64(stmt, 1, repo->workspace_key) != 0) { - rc = CBM_STORE_ERR; - } - while (rc == CBM_STORE_OK) { - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(stmt, &step) != 0) { - rc = CBM_STORE_ERR; - break; - } - if (step != ZOVA_STEP_ROW) break; - if ((uint64_t)count >= max_nodes) { - rc = CBM_STORE_SCAN_LIMIT; - break; - } - if (count == capacity) { - int grown_capacity = capacity ? capacity * 2 : 256; - cbm_zova_identity_node_t *grown = - realloc(nodes, (size_t)grown_capacity * sizeof(*grown)); - if (!grown) { - rc = CBM_STORE_ERR; - break; - } - nodes = grown; - capacity = grown_capacity; - } - cbm_zova_identity_node_t *node = &nodes[count]; - node->node_key = repo_column_i64(stmt, 0); - node->qualified_name = repo_column_text(stmt, 1); - node->label = repo_column_text(stmt, 2); - node->file_path = repo_column_text(stmt, 3); - count++; - if (!node->qualified_name || !node->label || !node->file_path) rc = CBM_STORE_ERR; - } - if (stmt) (void)zova_statement_finalize(stmt); - repo_identity_edges_t edges = {.max_edges = max_edges}; - if (rc == CBM_STORE_OK) { - rc = repo_scan_edges(repo, NULL, repo_collect_identity_edge, &edges); - if (edges.over_limit) rc = CBM_STORE_SCAN_LIMIT; - else if (rc != CBM_STORE_OK) rc = CBM_STORE_ERR; - } - if (rc != CBM_STORE_OK) { - cbm_zova_identities_free(nodes, count, edges.items, edges.count); - return rc; - } - *out_nodes = nodes; - *node_count = count; - *out_edges = edges.items; - *edge_count = edges.count; - return CBM_STORE_OK; -} - -void cbm_zova_identities_free(cbm_zova_identity_node_t *nodes, int node_count, - cbm_zova_identity_edge_t *edges, int edge_count) { - for (int i = 0; nodes && i < node_count; i++) { - free(nodes[i].qualified_name); - free(nodes[i].label); - free(nodes[i].file_path); - } - free(nodes); - for (int i = 0; edges && i < edge_count; i++) { - free(edges[i].type); - free(edges[i].local_name); - } - free(edges); -} - -int cbm_zova_repository_search_fts(cbm_zova_repository_t *repo, const char *workspace_id, - const char *query, - const char *file_pattern, int limit, int offset, - cbm_search_output_t *out) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || !query || !query[0] || - !out) return CBM_STORE_ERR; - memset(out, 0, sizeof(*out)); - if (limit <= 0) limit = 10; - zova_statement *stmt = NULL; - const char *sql = "SELECT n.zova_node_key,n.label,n.name,n.qualified_name,p.file_path," - "n.start_line,n.end_line,n.properties,(bm25(cbm_nodes_fts_v1)-CASE " - "WHEN n.label IN ('Function','Method') THEN 10.0 WHEN n.label='Route' THEN 8.0 " - "WHEN n.label IN ('Class','Interface','Type','Enum') THEN 5.0 ELSE 0.0 END) AS rank " - "FROM cbm_nodes_fts_v1 f " - "JOIN cbm_nodes_v1 n ON n.zova_node_key=f.rowid " - "JOIN cbm_files_v1 p ON p.file_key=n.file_key " - "WHERE n.workspace_key=?1 AND cbm_nodes_fts_v1 MATCH ?2 " - "AND n.label NOT IN ('File','Folder','Module','Section','Variable','Project') " - "AND (?3 IS NULL OR p.file_path GLOB ?3) " - "ORDER BY rank,n.qualified_name,n.zova_node_key LIMIT ?4 OFFSET ?5"; - if (repo_prepare(repo, sql, &stmt) != 0 || repo_bind_i64(stmt, 1, repo->workspace_key) != 0 || - repo_bind_text(stmt, 2, query) != 0) goto error; - zova_status bst = file_pattern && file_pattern[0] - ? zova_statement_bind_text(&(zova_statement_bind_text_request){ - .statement=stmt,.index=3,.data=(const uint8_t *)file_pattern, - .len=strlen(file_pattern)}) - : zova_statement_bind_null(&(zova_statement_bind_null_request){ - .statement=stmt,.index=3}); - if (bst != ZOVA_OK || repo_bind_i64(stmt, 4, limit) != 0 || - repo_bind_i64(stmt, 5, offset) != 0) goto error; - int cap = 8, count = 0; - cbm_search_result_t *results = calloc((size_t)cap, sizeof(*results)); - int64_t *keys = calloc((size_t)cap, sizeof(*keys)); - if (!results || !keys) { free(results); free(keys); goto error; } - for (;;) { - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(stmt, &step) != 0) { cbm_store_search_free(&(cbm_search_output_t){.results=results,.count=count}); goto error; } - if (step == ZOVA_STEP_DONE) break; - if (count == cap) { int old=cap; cap *= 2; cbm_search_result_t *g = realloc(results,(size_t)cap*sizeof(*g)); - int64_t *kg=realloc(keys,(size_t)cap*sizeof(*kg)); - if (!g || !kg) { free(kg); cbm_store_search_free(&(cbm_search_output_t){.results=g?g:results,.count=count}); goto error; } - results=g;keys=kg;memset(results+count,0,(size_t)(cap-old)*sizeof(*results)); } - cbm_node_t *n=&results[count].node; keys[count]=repo_column_i64(stmt,0); - n->project=repo_dup((const uint8_t *)repo->project,strlen(repo->project)); - n->label=repo_column_text(stmt,1); n->name=repo_column_text(stmt,2); - n->qualified_name=repo_column_text(stmt,3); n->file_path=repo_column_text(stmt,4); - n->start_line=(int)repo_column_i64(stmt,5); n->end_line=(int)repo_column_i64(stmt,6); - n->properties_json=repo_column_text(stmt,7); - results[count].rank=repo_column_double(stmt,8); - count++; - } - (void)zova_statement_finalize(stmt);stmt=NULL; - zova_graph_keyed_node_results native={0}; - if(repo_native_nodes_by_key(repo,keys,(size_t)count,&native)!=0){free(keys);cbm_store_search_free(&(cbm_search_output_t){.results=results,.count=count});goto error;} - for(int i=0;iresults=results; out->count=count; out->total=count; return CBM_STORE_OK; -error: - if (stmt) (void)zova_statement_finalize(stmt); - return CBM_STORE_ERR; -} - -int cbm_zova_repository_search(cbm_zova_repository_t *repo, const char *workspace_id, - const cbm_search_params_t *params, cbm_search_output_t *out) { - if (repo_workspace_required(repo, workspace_id) != CBM_STORE_OK || !params || !out) - return CBM_STORE_ERR; - memset(out, 0, sizeof(*out)); - char sql[4096] = "SELECT n.zova_node_key,n.label,n.name,n.qualified_name,f.file_path," - "n.start_line,n.end_line,n.properties FROM cbm_nodes_v1 n " - "JOIN cbm_files_v1 f ON f.file_key=n.file_key WHERE n.workspace_key=?1"; - const char *binds[64] = {0}; int bind_count = 0; -#define REPO_FILTER(clause, value) do { if ((value) && (value)[0]) { strncat(sql, (clause), sizeof(sql)-strlen(sql)-1); binds[bind_count++]=(value); } } while (0) - REPO_FILTER(" AND n.label=?", params->label); - REPO_FILTER(params->case_sensitive?" AND regexp(?,n.name)":" AND iregexp(?,n.name)", params->name_pattern); - REPO_FILTER(params->case_sensitive?" AND regexp(?,n.qualified_name)":" AND iregexp(?,n.qualified_name)", params->qn_pattern); - REPO_FILTER(" AND f.file_path GLOB ?", params->file_pattern); -#undef REPO_FILTER - if(params->exclude_labels){for(int i=0;params->exclude_labels[i]&&bind_count<63;i++){ - strncat(sql," AND n.label<>?",sizeof(sql)-strlen(sql)-1);binds[bind_count++]=params->exclude_labels[i];}} - strncat(sql, " ORDER BY n.name,n.qualified_name,n.zova_node_key", sizeof(sql)-strlen(sql)-1); - zova_statement *stmt=NULL; - if (repo_prepare(repo,sql,&stmt)!=0 || repo_bind_i64(stmt,1,repo->workspace_key)!=0) goto regular_error; - int idx=2; - for(int i=0;iproject=repo_dup((const uint8_t *)repo->project,strlen(repo->project));n->label=repo_column_text(stmt,1);n->name=repo_column_text(stmt,2);n->qualified_name=repo_column_text(stmt,3);n->file_path=repo_column_text(stmt,4);n->start_line=(int)repo_column_i64(stmt,5);n->end_line=(int)repo_column_i64(stmt,6);n->properties_json=repo_column_text(stmt,7);count++;} - (void)zova_statement_finalize(stmt);stmt=NULL; - zova_graph_keyed_node_results native={0};uint64_t*in=calloc((size_t)count,sizeof(*in));uint64_t*outd=calloc((size_t)count,sizeof(*outd));uint64_t*tmp=calloc((size_t)count,sizeof(*tmp));uint64_t*calls_in=calloc((size_t)count,sizeof(*calls_in));uint64_t*calls_out=calloc((size_t)count,sizeof(*calls_out));uint64_t*rel_in=calloc((size_t)count,sizeof(*rel_in));uint64_t*rel_out=calloc((size_t)count,sizeof(*rel_out)); - if((count&&(!in||!outd||!tmp||!calls_in||!calls_out||!rel_in||!rel_out))||repo_native_nodes_by_key(repo,keys,(size_t)count,&native)!=0)goto search_data_error; - const char*degree_types[]={"CALLS","USAGE","CALL_REFERENCE","INHERITS","IMPLEMENTS"}; - for(int t=0;t<(int)(sizeof(degree_types)/sizeof(degree_types[0]));t++){memset(tmp,0,(size_t)count*sizeof(*tmp));if(repo_degree_many(repo,keys,(size_t)count,ZOVA_GRAPH_NEIGHBOR_INCOMING,degree_types[t],tmp)!=0)goto search_data_error;for(int i=0;irelationship){if(repo_degree_many(repo,keys,(size_t)count,ZOVA_GRAPH_NEIGHBOR_INCOMING,params->relationship,rel_in)!=0||repo_degree_many(repo,keys,(size_t)count,ZOVA_GRAPH_NEIGHBOR_OUTGOING,params->relationship,rel_out)!=0)goto search_data_error;} - int limit=params->limit>0?params->limit:10;int page_capacity=limitrelationship){keep=!params->direction||strcmp(params->direction,"any")==0?rel_in[i]+rel_out[i]>0:strcmp(params->direction,"inbound")==0?rel_in[i]>0:rel_out[i]>0;}if(keep&¶ms->exclude_entry_points&&calls_in[i]==0&&calls_out[i]>0)keep=false;if(keep&¶ms->min_degree>=0&°reemin_degree)keep=false;if(keep&¶ms->max_degree>=0&°ree>params->max_degree)keep=false;if(!keep)continue;if(accepted>=params->offset&&writtenresults=page;out->count=written;out->total=accepted;return CBM_STORE_OK; -search_data_error: zova_graph_keyed_node_results_free(&native);free(keys);free(in);free(outd);free(tmp);free(calls_in);free(calls_out);free(rel_in);free(rel_out);cbm_store_search_free(&(cbm_search_output_t){.results=results,.count=count}); -regular_error: if(stmt)(void)zova_statement_finalize(stmt);return CBM_STORE_ERR; -} - -void cbm_zova_workspace_snapshot_free(cbm_zova_workspace_snapshot_t *snapshot) { - if (!snapshot) return; - free(snapshot->root_path); - free(snapshot->project); - free(snapshot->indexed_at); - free(snapshot->model_fingerprint); - for (int i = 0; i < snapshot->node_count; i++) { - free(snapshot->node_stable_ids ? snapshot->node_stable_ids[i] : NULL); - free((char *)snapshot->nodes[i].project); - free((char *)snapshot->nodes[i].label); - free((char *)snapshot->nodes[i].name); - free((char *)snapshot->nodes[i].qualified_name); - free((char *)snapshot->nodes[i].file_path); - free((char *)snapshot->nodes[i].properties); - } - free(snapshot->node_stable_ids); - free(snapshot->zova_node_keys); - free(snapshot->node_source_ordinals); - free(snapshot->nodes); - for (int i = 0; i < snapshot->edge_count; i++) { - free(snapshot->edge_ids ? snapshot->edge_ids[i] : NULL); - if (snapshot->edges[i].project != snapshot->project) - free((char *)snapshot->edges[i].project); - if (!snapshot->edge_string_arena) { - free((char *)snapshot->edges[i].type); - free((char *)snapshot->edges[i].properties); - free((char *)snapshot->edges[i].url_path); - free((char *)snapshot->edges[i].local_name); - } - } - free(snapshot->edge_ids); - free(snapshot->edges); - repo_string_arena_free(snapshot->edge_string_arena); - for (int i = 0; i < snapshot->topology_edge_count; i++) { - free(snapshot->topology_edges[i].source_stable_id); - free(snapshot->topology_edges[i].edge_type); - free(snapshot->topology_edges[i].target_stable_id); - } - free(snapshot->topology_edges); - for (int i = 0; i < snapshot->node_vector_count; i++) { - free(snapshot->node_vector_ids ? snapshot->node_vector_ids[i] : NULL); - free((char *)snapshot->node_vectors[i].project); - free((void *)snapshot->node_vectors[i].vector); - } - free(snapshot->node_vector_ids); - free(snapshot->node_vectors); - for (int i = 0; i < snapshot->token_vector_count; i++) { - free(snapshot->token_vector_ids ? snapshot->token_vector_ids[i] : NULL); - free((char *)snapshot->token_vectors[i].project); - free((char *)snapshot->token_vectors[i].token); - free((void *)snapshot->token_vectors[i].vector); - } - free(snapshot->token_vector_ids); - free(snapshot->token_vectors); - for (int i = 0; i < snapshot->file_hash_count; i++) { - free((char *)snapshot->file_hashes[i].file_path); - free((char *)snapshot->file_hashes[i].content_hash); - } - free(snapshot->file_hashes); - free((char *)snapshot->project_summary.summary); - free((char *)snapshot->project_summary.source_hash); - free((char *)snapshot->project_summary.created_at); - free((char *)snapshot->project_summary.updated_at); - memset(snapshot, 0, sizeof(*snapshot)); -} - -static int snapshot_count(cbm_zova_repository_t *repo, const char *sql) { - zova_statement *statement = NULL; - if (repo_prepare(repo, sql, &statement) != 0 || - repo_bind_i64(statement, 1, repo->workspace_key) != 0) { - if (statement) (void)zova_statement_finalize(statement); - return -1; - } - zova_step_result step = ZOVA_STEP_DONE; - int count = repo_step(statement, &step) == 0 && step == ZOVA_STEP_ROW - ? (int)repo_column_i64(statement, 0) - : -1; - (void)zova_statement_finalize(statement); - return count; -} - -typedef struct { - int64_t node_key; - int64_t snapshot_id; -} snapshot_node_key_ref_t; - -static int snapshot_node_key_ref_compare(const void *left_ptr, const void *right_ptr) { - const snapshot_node_key_ref_t *left = left_ptr; - const snapshot_node_key_ref_t *right = right_ptr; - return (left->node_key > right->node_key) - (left->node_key < right->node_key); -} - -static int64_t snapshot_map_node_key(const snapshot_node_key_ref_t *refs, int count, - int64_t node_key) { - int low = 0, high = count; - while (low < high) { - int mid = low + (high - low) / 2; - if (refs[mid].node_key == node_key) return refs[mid].snapshot_id; - if (refs[mid].node_key < node_key) low = mid + 1; - else high = mid; - } - return -1; -} - -typedef struct { - int64_t node_key; - char *stable_id; - CBMDumpNode node; - uint64_t source_ordinal; -} snapshot_node_row_t; - -static int snapshot_node_row_compare(const void *left_ptr, const void *right_ptr) { - const snapshot_node_row_t *left = left_ptr; - const snapshot_node_row_t *right = right_ptr; - return strcmp(left->stable_id, right->stable_id); -} - -static int snapshot_read_nodes(cbm_zova_repository_t *repo, - cbm_zova_workspace_snapshot_t *snapshot, - char ***out_stable_ids, - snapshot_node_key_ref_t **out_node_keys) { - struct timespec phase_started = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_started); - int count = snapshot_count(repo, - "SELECT count(*) FROM cbm_nodes_v1 WHERE workspace_key=?1"); - if (count < 0) return -1; - snapshot->node_count = count; - snapshot->nodes = count ? calloc((size_t)count, sizeof(*snapshot->nodes)) : NULL; - char **stable_ids = count ? calloc((size_t)count, sizeof(*stable_ids)) : NULL; - snapshot_node_key_ref_t *node_keys = - count ? calloc((size_t)count, sizeof(*node_keys)) : NULL; - snapshot_node_row_t *rows = count ? calloc((size_t)count, sizeof(*rows)) : NULL; - int64_t *keys = count ? calloc((size_t)count, sizeof(*keys)) : NULL; - snapshot->node_source_ordinals = - count ? calloc((size_t)count, sizeof(*snapshot->node_source_ordinals)) : NULL; - snapshot->zova_node_keys = - count ? calloc((size_t)count, sizeof(*snapshot->zova_node_keys)) : NULL; - if (count && (!snapshot->nodes || !stable_ids || !node_keys || !rows || !keys || - !snapshot->node_source_ordinals || !snapshot->zova_node_keys)) { - free(stable_ids); - free(node_keys); - free(rows); - free(keys); - return -1; - } - zova_statement *statement = NULL; - int rc = repo_prepare(repo, - "SELECT n.zova_node_key,n.name,n.qualified_name,f.file_path,n.start_line," - "n.end_line,n.properties,n.source_ordinal FROM cbm_nodes_v1 n " - "JOIN cbm_files_v1 f ON f.file_key=n.file_key " - "WHERE n.workspace_key=?1 ORDER BY n.zova_node_key", &statement); - if (rc == 0) rc = repo_bind_i64(statement, 1, repo->workspace_key); - for (int i = 0; rc == 0 && i < count; i++) { - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(statement, &step) != 0 || step != ZOVA_STEP_ROW) { rc = -1; break; } - snapshot_node_row_t *row = &rows[i]; - CBMDumpNode *node = &row->node; - row->node_key = keys[i] = repo_column_i64(statement, 0); - node->project = repo_dup((const uint8_t *)snapshot->project, - strlen(snapshot->project)); - node->name = repo_column_text(statement, 1); - node->qualified_name = repo_column_text(statement, 2); - node->file_path = repo_column_text(statement, 3); - node->start_line = (int)repo_column_i64(statement, 4); - node->end_line = (int)repo_column_i64(statement, 5); - node->properties = repo_column_text(statement, 6); - row->source_ordinal = (uint64_t)repo_column_i64(statement, 7); - if (!node->project || !node->name || !node->qualified_name || !node->file_path || - !node->properties || row->node_key <= 0) rc = -1; - } - if (statement) (void)zova_statement_finalize(statement); - snapshot->metrics.nodes_sql_ms = repo_elapsed_ms(phase_started); - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_started); - zova_graph_keyed_node_results native = {0}; - if (rc == 0 && repo_native_nodes_by_key(repo, keys, (size_t)count, &native) != 0) rc = -1; - snapshot->metrics.nodes_native_ms = repo_elapsed_ms(phase_started); - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_started); - for (int i = 0; rc == 0 && i < count; i++) { - if (!native.items[i].found || native.items[i].node_key != rows[i].node_key || - !native.items[i].node_id || !native.items[i].kind) { - fprintf(stderr, "zova keyed node mismatch index=%d requested=%lld found=%u returned=%lld\n", - i, (long long)rows[i].node_key, native.items[i].found, - (long long)native.items[i].node_key); - rc = -1; break; - } - rows[i].stable_id = repo_dup((const uint8_t *)native.items[i].node_id, - native.items[i].node_id_len); - rows[i].node.label = repo_dup((const uint8_t *)native.items[i].kind, - native.items[i].kind_len); - if (!rows[i].stable_id || !rows[i].node.label) rc = -1; - } - zova_graph_keyed_node_results_free(&native); - free(keys); - if (rc != 0) { - for (int i = 0; i < count; i++) { - free(rows[i].stable_id); - free((char *)rows[i].node.project); free((char *)rows[i].node.label); - free((char *)rows[i].node.name); free((char *)rows[i].node.qualified_name); - free((char *)rows[i].node.file_path); free((char *)rows[i].node.properties); - } - free(stable_ids); - free(node_keys); - free(rows); - return -1; - } - if (count > 1) qsort(rows, (size_t)count, sizeof(*rows), snapshot_node_row_compare); - for (int i = 0; i < count; i++) { - rows[i].node.id = i + 1; - snapshot->nodes[i] = rows[i].node; - stable_ids[i] = rows[i].stable_id; - snapshot->zova_node_keys[i] = rows[i].node_key; - snapshot->node_source_ordinals[i] = rows[i].source_ordinal; - node_keys[i] = (snapshot_node_key_ref_t){.node_key = rows[i].node_key, - .snapshot_id = i + 1}; - } - free(rows); - if (count > 1) - qsort(node_keys, (size_t)count, sizeof(*node_keys), snapshot_node_key_ref_compare); - snapshot->node_stable_ids = stable_ids; - *out_stable_ids = stable_ids; - *out_node_keys = node_keys; - snapshot->metrics.nodes_finalize_ms = repo_elapsed_ms(phase_started); - snapshot->metrics.node_rows = (uint64_t)count; - snapshot->metrics.base_phase_mask |= CBM_ZOVA_SNAPSHOT_BASE_PHASE_NODES; - return 0; -} - -typedef struct { - CBMDumpEdge edge; - char *edge_id; -} snapshot_edge_sort_row_t; - -static int snapshot_edge_sort_row_compare(const void *left_ptr, const void *right_ptr) { - const snapshot_edge_sort_row_t *left = left_ptr; - const snapshot_edge_sort_row_t *right = right_ptr; - return strcmp(left->edge_id, right->edge_id); -} - -typedef struct { - cbm_zova_workspace_snapshot_t *snapshot; - repo_string_arena_t *arena; - const snapshot_node_key_ref_t *node_keys; - const zova_graph_scan_edge *native; - int expected_count; - int *written; -} snapshot_payload_context_t; - -static int snapshot_append_payload_edge( - const cbm_zova_edge_payload_record_t *record, void *context_ptr) { - snapshot_payload_context_t *context = context_ptr; - if (!record || !context || !context->snapshot || !context->written || - *context->written >= context->expected_count) return -1; - int64_t source_id = snapshot_map_node_key( - context->node_keys, context->snapshot->node_count, - context->native->source_node_key); - int64_t target_id = snapshot_map_node_key( - context->node_keys, context->snapshot->node_count, - context->native->target_node_key); - if (source_id <= 0 || target_id <= 0) return -1; - CBMDumpEdge *edge = &context->snapshot->edges[*context->written]; - *edge = (CBMDumpEdge){ - .id = *context->written + 1, - .project = context->snapshot->project, - .source_id = source_id, - .target_id = target_id, - .type = repo_string_arena_dup( - context->arena, (const uint8_t *)context->native->edge_type, - context->native->edge_type_len), - .properties = repo_string_arena_dup( - context->arena, record->properties.data, record->properties.len), - .url_path = repo_string_arena_dup( - context->arena, record->url_path.data, record->url_path.len), - .local_name = repo_string_arena_dup( - context->arena, record->local_name.data, record->local_name.len), - }; - if (!edge->type || !edge->properties || !edge->url_path || !edge->local_name) - return -1; - (*context->written)++; - return 0; -} - -static int snapshot_read_edges(cbm_zova_repository_t *repo, - cbm_zova_workspace_snapshot_t *snapshot, - const snapshot_node_key_ref_t *node_keys) { - const char *failure = NULL; - struct timespec phase_started = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_started); - if (snapshot->integrity.metadata_edges > INT_MAX) return -1; - int count = (int)snapshot->integrity.metadata_edges; - snapshot->edge_count = count; - snapshot->edges = count ? calloc((size_t)count, sizeof(*snapshot->edges)) : NULL; - snapshot->edge_ids = count ? calloc((size_t)count, sizeof(*snapshot->edge_ids)) : NULL; - snapshot->edge_string_arena = count ? calloc(1, sizeof(repo_string_arena_t)) : NULL; - if (count && (!snapshot->edges || !snapshot->edge_ids || - !snapshot->edge_string_arena)) return -1; - repo_string_arena_t *arena = snapshot->edge_string_arena; - snapshot->metrics.edges_sql_ms = repo_elapsed_ms(phase_started); - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_started); - int rc = 0; - char graph_name[REPO_GRAPH_NAME_CAP]; - if (rc == 0 && repo_graph_name(repo, graph_name, sizeof(graph_name)) != 0) { - failure = "graph_name"; - rc = -1; - } - zova_graph_scan_cursor cursor = {0}; - int written = 0; - while (rc == 0) { - zova_graph_scan_results page = {0}; - zova_status status = zova_graph_scan(&(zova_graph_scan_request){ - .db = repo->db, .graph_name = graph_name, .edge_after = cursor, - .node_limit = 0, .edge_limit = REPO_EDGE_SCAN_PAGE, .out_results = &page}); - if (status != ZOVA_OK) { - fprintf(stderr, "zova snapshot graph scan failed status=%s\n", - zova_status_name(status)); - failure = "graph_scan"; - zova_graph_scan_results_free(&page); - rc = -1; - break; - } - if (page.edges_len) snapshot->metrics.edge_scan_pages++; - snapshot->metrics.edge_native_rows += page.edges_len; - int64_t *edge_keys = page.edges_len - ? calloc(page.edges_len, sizeof(*edge_keys)) : NULL; - zova_graph_edge_payload_results payloads = {0}; - if (page.edges_len && !edge_keys) rc = -1; - for (size_t i = 0; rc == 0 && i < page.edges_len; i++) - edge_keys[i] = page.edges[i].edge_key; - if (rc == 0 && page.edges_len && - zova_graph_edge_payload_get_many( - &(zova_graph_edge_payload_get_many_request){ - .db = repo->db, - .graph_name = graph_name, - .edge_keys = edge_keys, - .key_count = page.edges_len, - .out_results = &payloads, - }) != ZOVA_OK) rc = -1; - if (rc == 0 && payloads.len != page.edges_len) rc = -1; - snapshot->metrics.edge_keyed_read_count += payloads.len; - for (size_t i = 0; rc == 0 && i < page.edges_len; i++) { - if (!payloads.items[i].found || - payloads.items[i].edge_key != page.edges[i].edge_key) { - failure = "payload_identity"; - rc = -1; - break; - } - snapshot_payload_context_t context = { - .snapshot = snapshot, - .arena = arena, - .node_keys = node_keys, - .native = &page.edges[i], - .expected_count = count, - .written = &written, - }; - if (cbm_zova_edge_payload_visit( - payloads.items[i].payload, payloads.items[i].payload_len, - snapshot_append_payload_edge, &context, NULL) != 0) { - failure = "payload_decode"; - rc = -1; - } - } - zova_graph_edge_payload_results_free(&payloads); - free(edge_keys); - if (page.edges_len) { - const zova_graph_scan_edge *last = &page.edges[page.edges_len - 1]; - cursor = (zova_graph_scan_cursor){ - .created_order = last->created_order, - .key = last->edge_key, - }; - } - uint8_t more = page.has_more_edges; - zova_graph_scan_results_free(&page); - if (!more) break; - } - snapshot->metrics.edge_logical_rows = (uint64_t)written; - if (rc == 0 && written != count) rc = -1; - if (rc != 0 && failure) - fprintf(stderr, "zova snapshot edge read failed stage=%s\n", failure); - snapshot->metrics.edges_native_ms = repo_elapsed_ms(phase_started); - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_started); - snapshot_edge_sort_row_t *rows = count - ? calloc((size_t)count, sizeof(*rows)) : NULL; - if (rc == 0 && count && !rows) rc = -1; - for (int i = 0; rc == 0 && i < count; i++) { - char edge_id[CBM_ZOVA_WORKSPACE_ID_MAX + 80]; - CBMDumpEdge *edge = &snapshot->edges[i]; - if (repo_edge_id(snapshot->workspace_id, - snapshot->node_stable_ids[edge->source_id - 1], edge->type, - snapshot->node_stable_ids[edge->target_id - 1], - edge->local_name, edge_id, sizeof(edge_id)) != 0 || - !(rows[i].edge_id = repo_dup((const uint8_t *)edge_id, strlen(edge_id)))) { - rc = -1; - break; - } - rows[i].edge = *edge; - } - if (rc == 0 && count > 1) - qsort(rows, (size_t)count, sizeof(*rows), snapshot_edge_sort_row_compare); - for (int i = 0; rc == 0 && i < count; i++) { - snapshot->edges[i] = rows[i].edge; - snapshot->edges[i].id = i + 1; - snapshot->edge_ids[i] = rows[i].edge_id; - rows[i].edge_id = NULL; - } - for (int i = 0; rows && i < count; i++) free(rows[i].edge_id); - free(rows); - snapshot->metrics.edges_finalize_ms = repo_elapsed_ms(phase_started); - snapshot->metrics.edge_rows = (uint64_t)count; - if (arena) { - snapshot->metrics.edge_string_arena_chunks = arena->chunk_count; - snapshot->metrics.edge_string_arena_bytes = arena->bytes_used; - } - snapshot->metrics.base_phase_mask |= CBM_ZOVA_SNAPSHOT_BASE_PHASE_EDGES; - return rc; -} - -int cbm_zova_workspace_snapshot_format_edge_id( - const cbm_zova_workspace_snapshot_t *snapshot, int edge_index, - char *out, size_t out_size) { - if (!snapshot || edge_index < 0 || edge_index >= snapshot->edge_count || - !out || out_size == 0) return -1; - if (snapshot->edge_ids && snapshot->edge_ids[edge_index]) { - int written = snprintf(out, out_size, "%s", snapshot->edge_ids[edge_index]); - return written >= 0 && (size_t)written < out_size ? 0 : -1; - } - const CBMDumpEdge *edge = &snapshot->edges[edge_index]; - if (edge->source_id <= 0 || edge->source_id > snapshot->node_count || - edge->target_id <= 0 || edge->target_id > snapshot->node_count || !edge->type) - return -1; - return repo_edge_id(snapshot->workspace_id, - snapshot->node_stable_ids[edge->source_id - 1], edge->type, - snapshot->node_stable_ids[edge->target_id - 1], - edge->local_name ? edge->local_name : "", out, out_size); -} - -typedef struct { - const char *source_stable_id; - const char *edge_type; - const char *target_stable_id; -} snapshot_topology_ref_t; - -static int snapshot_topology_ref_compare(const void *left_ptr, const void *right_ptr) { - const snapshot_topology_ref_t *left = left_ptr; - const snapshot_topology_ref_t *right = right_ptr; - int order = strcmp(left->source_stable_id, right->source_stable_id); - if (order == 0) order = strcmp(left->edge_type, right->edge_type); - if (order == 0) order = strcmp(left->target_stable_id, right->target_stable_id); - return order; -} - -static int snapshot_build_topology(const cbm_zova_workspace_snapshot_t *snapshot, - cbm_zova_workspace_snapshot_t *out) { - if (!snapshot || !out || snapshot->edge_count < 0) return -1; - snapshot_topology_ref_t *refs = snapshot->edge_count - ? calloc((size_t)snapshot->edge_count, sizeof(*refs)) : NULL; - if (snapshot->edge_count && !refs) return -1; - for (int i = 0; i < snapshot->edge_count; i++) { - const CBMDumpEdge *edge = &snapshot->edges[i]; - if (edge->source_id <= 0 || edge->source_id > snapshot->node_count || - edge->target_id <= 0 || edge->target_id > snapshot->node_count || !edge->type) { - free(refs); - return -1; - } - refs[i] = (snapshot_topology_ref_t){ - .source_stable_id = snapshot->node_stable_ids[edge->source_id - 1], - .edge_type = edge->type, - .target_stable_id = snapshot->node_stable_ids[edge->target_id - 1], - }; - if (!refs[i].source_stable_id || !refs[i].target_stable_id) { - free(refs); - return -1; - } - } - if (snapshot->edge_count > 1) - qsort(refs, (size_t)snapshot->edge_count, sizeof(*refs), snapshot_topology_ref_compare); - int unique_count = 0; - for (int i = 0; i < snapshot->edge_count; i++) { - if (i == 0 || snapshot_topology_ref_compare(&refs[i - 1], &refs[i]) != 0) - unique_count++; - } - if ((uint64_t)unique_count != snapshot->integrity.metadata_topology_edges) { - free(refs); - return -1; - } - out->topology_edges = unique_count - ? calloc((size_t)unique_count, sizeof(*out->topology_edges)) : NULL; - if (unique_count && !out->topology_edges) { - free(refs); - return -1; - } - out->topology_edge_count = unique_count; - int written = 0; - for (int i = 0; i < snapshot->edge_count; i++) { - if (i > 0 && snapshot_topology_ref_compare(&refs[i - 1], &refs[i]) == 0) continue; - cbm_zova_snapshot_topology_edge_t *row = &out->topology_edges[written++]; - row->source_stable_id = repo_dup((const uint8_t *)refs[i].source_stable_id, - strlen(refs[i].source_stable_id)); - row->edge_type = repo_dup((const uint8_t *)refs[i].edge_type, - strlen(refs[i].edge_type)); - row->target_stable_id = repo_dup((const uint8_t *)refs[i].target_stable_id, - strlen(refs[i].target_stable_id)); - if (!row->source_stable_id || !row->edge_type || !row->target_stable_id) { - free(refs); - return -1; - } - } - free(refs); - return 0; -} - -static int snapshot_read_node_vectors(cbm_zova_repository_t *repo, - const cbm_zova_workspace_snapshot_t *snapshot, - cbm_zova_workspace_snapshot_t *out) { - char node_collection[CBM_ZOVA_WORKSPACE_ID_MAX + 128]; - if (cbm_zova_workspace_node_vector_collection_name( - repo->workspace_id, snapshot->model_fingerprint, snapshot->vector_dimensions, - node_collection, sizeof(node_collection)) != 0) - return -1; - zova_vector_collection_info node_info = {0}; - zova_status node_status = zova_vector_collection_info_get( - &(zova_vector_collection_info_get_request){ - .db = repo->db, .name = node_collection, .out_info = &node_info}); - int rc = node_status == ZOVA_OK && - node_info.element_type == ZOVA_VECTOR_ELEMENT_TYPE_I8 && - node_info.dimensions == (uint32_t)snapshot->vector_dimensions && - node_info.vector_count <= INT32_MAX && - node_info.vector_count == snapshot->integrity.node_vectors - ? 0 - : -1; - int node_count = rc == 0 ? (int)node_info.vector_count : 0; - if (node_status == ZOVA_OK) zova_vector_collection_info_free(&node_info); - out->node_vectors = node_count ? calloc((size_t)node_count, sizeof(*out->node_vectors)) : NULL; - out->node_vector_ids = node_count ? calloc((size_t)node_count, sizeof(*out->node_vector_ids)) : NULL; - out->node_vector_count = node_count; - if (rc == 0 && node_count && (!out->node_vectors || !out->node_vector_ids)) - rc = -1; - int node_written = 0; - for (int i = 0; rc == 0 && i < snapshot->node_count; i++) { - char vector_id[32]; - if (!snapshot->zova_node_keys || - snprintf(vector_id, sizeof(vector_id), "%lld", - (long long)snapshot->zova_node_keys[i]) >= (int)sizeof(vector_id)) { - rc = -1; - break; - } - uint8_t exists = 0; - if (zova_vector_exists(&(zova_vector_exists_request){ - .db = repo->db, .collection_name = node_collection, - .vector_id = vector_id, .out_exists = &exists}) != ZOVA_OK) { - rc = -1; - break; - } - if (!exists) continue; - if (node_written >= node_count) { rc = -1; break; } - zova_vector native = {0}; - if (zova_vector_get(&(zova_vector_get_request){ - .db = repo->db, .collection_name = node_collection, - .vector_id = vector_id, .out_vector = &native}) != ZOVA_OK || - native.element_type != ZOVA_VECTOR_ELEMENT_TYPE_I8 || - native.values_len != (size_t)snapshot->vector_dimensions) { - zova_vector_free(&native); - rc = -1; - break; - } - CBMDumpVector *vector = &out->node_vectors[node_written++]; - out->node_vector_ids[node_written - 1] = - repo_dup((const uint8_t *)snapshot->node_stable_ids[i], - strlen(snapshot->node_stable_ids[i])); - vector->node_id = snapshot->nodes[i].id; - vector->project = repo_dup((const uint8_t *)snapshot->project, strlen(snapshot->project)); - uint8_t *bytes = malloc(native.values_len); - if (!out->node_vector_ids[node_written - 1] || !vector->project || !bytes) rc = -1; - else { - memcpy(bytes, native.i8_values, native.values_len); - vector->vector = bytes; - vector->vector_len = (int)native.values_len; - } - zova_vector_free(&native); - } - if (rc == 0 && node_written != node_count) rc = -1; - return rc; -} - -typedef struct { - int64_t token_key; - char *public_id; - char *token; - double idf; -} snapshot_token_row_t; - -static int snapshot_token_row_compare(const void *left_ptr, const void *right_ptr) { - const snapshot_token_row_t *left = left_ptr; - const snapshot_token_row_t *right = right_ptr; - return strcmp(left->public_id, right->public_id); -} - -static int snapshot_read_token_vectors(cbm_zova_repository_t *repo, - const cbm_zova_workspace_snapshot_t *snapshot, - cbm_zova_workspace_snapshot_t *out) { - char token_collection[CBM_ZOVA_WORKSPACE_ID_MAX + 128]; - if (cbm_zova_workspace_token_vector_collection_name( - repo->workspace_id, snapshot->model_fingerprint, snapshot->vector_dimensions, - token_collection, sizeof(token_collection)) != 0) - return -1; - zova_vector_collection_info token_info = {0}; - zova_status token_status = zova_vector_collection_info_get( - &(zova_vector_collection_info_get_request){ - .db = repo->db, .name = token_collection, .out_info = &token_info}); - int rc = token_status == ZOVA_OK && - token_info.element_type == ZOVA_VECTOR_ELEMENT_TYPE_I8 && - token_info.dimensions == (uint32_t)snapshot->vector_dimensions && - token_info.vector_count <= INT32_MAX && - token_info.vector_count == snapshot->integrity.token_vectors - ? 0 - : -1; - int token_count = rc == 0 ? (int)token_info.vector_count : 0; - if (token_status == ZOVA_OK) zova_vector_collection_info_free(&token_info); - out->token_vectors = token_count ? calloc((size_t)token_count, sizeof(*out->token_vectors)) : NULL; - out->token_vector_ids = token_count ? calloc((size_t)token_count, sizeof(*out->token_vector_ids)) : NULL; - out->token_vector_count = token_count; - if (rc == 0 && token_count && (!out->token_vectors || !out->token_vector_ids)) rc = -1; - snapshot_token_row_t *rows = token_count - ? calloc((size_t)token_count, sizeof(*rows)) : NULL; - if (rc == 0 && token_count && !rows) rc = -1; - zova_statement *statement = NULL; - if (rc == 0) rc = repo_prepare(repo, - "SELECT token_key,token,idf FROM cbm_token_vector_metadata_v1 " - "WHERE workspace_key=?1 ORDER BY token_key", &statement); - if (rc == 0) rc = repo_bind_i64(statement, 1, repo->workspace_key); - for (int i = 0; rc == 0 && i < token_count; i++) { - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(statement, &step) != 0 || step != ZOVA_STEP_ROW) { rc = -1; break; } - rows[i].token_key = repo_column_i64(statement, 0); - rows[i].token = repo_column_text(statement, 1); - rows[i].idf = repo_column_double(statement, 2); - char public_id[CBM_ZOVA_WORKSPACE_ID_MAX + 64]; - if (rows[i].token_key <= 0 || !rows[i].token || - cbm_zova_workspace_token_id_v1(repo->workspace_id, rows[i].token, - public_id, sizeof(public_id)) != 0) { - rc = -1; - break; - } - rows[i].public_id = repo_dup((const uint8_t *)public_id, strlen(public_id)); - if (!rows[i].public_id) rc = -1; - } - if (statement) { - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0 && (repo_step(statement, &step) != 0 || step != ZOVA_STEP_DONE)) rc = -1; - (void)zova_statement_finalize(statement); - statement = NULL; - } - if (rc == 0 && token_count > 1) - qsort(rows, (size_t)token_count, sizeof(*rows), snapshot_token_row_compare); - int token_written = 0; - for (; rc == 0 && token_written < token_count; token_written++) { - char vector_id[32]; - if (snprintf(vector_id, sizeof(vector_id), "%lld", - (long long)rows[token_written].token_key) >= (int)sizeof(vector_id)) { - rc = -1; - break; - } - zova_vector native = {0}; - if (zova_vector_get(&(zova_vector_get_request){ - .db = repo->db, .collection_name = token_collection, - .vector_id = vector_id, .out_vector = &native}) != ZOVA_OK || - native.element_type != ZOVA_VECTOR_ELEMENT_TYPE_I8 || - native.values_len != (size_t)snapshot->vector_dimensions) { - zova_vector_free(&native); rc = -1; break; - } - CBMDumpTokenVec *vector = &out->token_vectors[token_written]; - out->token_vector_ids[token_written] = rows[token_written].public_id; - rows[token_written].public_id = NULL; - vector->id = token_written + 1; - vector->project = repo_dup((const uint8_t *)snapshot->project, strlen(snapshot->project)); - vector->token = rows[token_written].token; - rows[token_written].token = NULL; - vector->idf = (float)rows[token_written].idf; - uint8_t *bytes = malloc(native.values_len); - if (vector->id <= 0 || !vector->project || !bytes) rc = -1; - else { - memcpy(bytes, native.i8_values, native.values_len); - vector->vector = bytes; - vector->vector_len = (int)native.values_len; - } - zova_vector_free(&native); - } - for (int i = 0; i < token_count; i++) { - free(rows ? rows[i].public_id : NULL); - free(rows ? rows[i].token : NULL); - } - free(rows); - if (rc == 0 && token_written != token_count) rc = -1; - return rc; -} - -static int snapshot_read_hashes_summary(cbm_zova_repository_t *repo, - cbm_zova_workspace_snapshot_t *snapshot) { - struct timespec phase_started = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_started); - int count = snapshot_count(repo, - "SELECT count(*) FROM cbm_file_hashes_v1 h JOIN cbm_files_v1 f " - "ON f.file_key=h.file_key WHERE f.workspace_key=?1"); - if (count < 0) return -1; - snapshot->file_hash_count = count; - snapshot->file_hashes = count ? calloc((size_t)count, sizeof(*snapshot->file_hashes)) : NULL; - if (count && !snapshot->file_hashes) return -1; - zova_statement *statement = NULL; - int rc = repo_prepare(repo, - "SELECT f.file_path,h.content_hash,h.mtime_ns,h.size_bytes FROM cbm_file_hashes_v1 h " - "JOIN cbm_files_v1 f ON f.file_key=h.file_key " - "WHERE f.workspace_key=?1 ORDER BY f.file_path", &statement); - if (rc == 0) rc = repo_bind_i64(statement, 1, repo->workspace_key); - for (int i = 0; rc == 0 && i < count; i++) { - zova_step_result step = ZOVA_STEP_DONE; - if (repo_step(statement, &step) != 0 || step != ZOVA_STEP_ROW) { rc = -1; break; } - snapshot->file_hashes[i].file_path = repo_column_text(statement, 0); - snapshot->file_hashes[i].content_hash = repo_column_text(statement, 1); - snapshot->file_hashes[i].mtime_ns = repo_column_i64(statement, 2); - snapshot->file_hashes[i].size_bytes = repo_column_i64(statement, 3); - if (!snapshot->file_hashes[i].file_path || !snapshot->file_hashes[i].content_hash) rc = -1; - } - if (statement) (void)zova_statement_finalize(statement); - statement = NULL; - if (rc == 0) rc = repo_prepare(repo, - "SELECT summary,source_hash,created_at,updated_at FROM cbm_project_summaries_v2 " - "WHERE workspace_key=?1", &statement); - if (rc == 0) rc = repo_bind_i64(statement, 1, repo->workspace_key); - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0 && repo_step(statement, &step) != 0) rc = -1; - if (rc == 0 && step == ZOVA_STEP_ROW) { - snapshot->project_summary.present = true; - snapshot->project_summary.summary = repo_column_text(statement, 0); - snapshot->project_summary.source_hash = repo_column_text(statement, 1); - snapshot->project_summary.created_at = repo_column_text(statement, 2); - snapshot->project_summary.updated_at = repo_column_text(statement, 3); - if (!snapshot->project_summary.summary || !snapshot->project_summary.source_hash || - !snapshot->project_summary.created_at || !snapshot->project_summary.updated_at) rc = -1; - } - if (statement) (void)zova_statement_finalize(statement); - snapshot->metrics.hashes_summary_ms = repo_elapsed_ms(phase_started); - snapshot->metrics.file_hash_rows = (uint64_t)count; - snapshot->metrics.base_phase_mask |= CBM_ZOVA_SNAPSHOT_BASE_PHASE_HASHES_SUMMARY; - return rc; -} - -static int snapshot_read_header(cbm_zova_repository_t *repo, - cbm_zova_workspace_snapshot_t *snapshot) { - zova_statement *statement = NULL; - int rc = repo_prepare(repo, - "SELECT r.workspace_key,r.canonical_root,p.root_path,p.project,p.indexed_at," - "s.model_fingerprint,s.vector_dimensions,r.active_generation " - "FROM cbm_workspace_registry r " - "JOIN cbm_projects_v1 p ON p.workspace_key=r.workspace_key " - "JOIN cbm_workspace_index_state_v1 s ON s.workspace_key=r.workspace_key " - "JOIN cbm_database_generation_v1 g ON g.workspace_key=r.workspace_key " - "AND g.generation=r.active_generation AND g.state='ready' " - "WHERE r.workspace_id=?1 AND s.generation=r.active_generation", &statement); - if (rc == 0) rc = repo_bind_text(statement, 1, repo->workspace_id); - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0 && (repo_step(statement, &step) != 0 || step != ZOVA_STEP_ROW)) rc = -1; - if (rc == 0) { - repo->workspace_key = repo_column_i64(statement, 0); - snapshot->root_path = repo_column_text(statement, 1); - char *project_root = repo_column_text(statement, 2); - snapshot->project = repo_column_text(statement, 3); - snapshot->indexed_at = repo_column_text(statement, 4); - snapshot->model_fingerprint = repo_column_text(statement, 5); - snapshot->vector_dimensions = (int)repo_column_i64(statement, 6); - snapshot->generation = repo_column_i64(statement, 7); - if (!snapshot->root_path || !snapshot->project || !snapshot->indexed_at || - !snapshot->model_fingerprint || snapshot->vector_dimensions <= 0 || - snapshot->generation <= 0) rc = -1; - char expected[CBM_ZOVA_WORKSPACE_ID_MAX]; - if (rc == 0 && - (!project_root || strcmp(project_root, snapshot->root_path) != 0 || - cbm_zova_workspace_id_for_root(snapshot->root_path, expected, sizeof(expected)) != 0 || - strcmp(expected, repo->workspace_id) != 0)) rc = -1; - free(project_root); - } - if (rc == 0 && (repo_step(statement, &step) != 0 || step != ZOVA_STEP_DONE)) rc = -1; - if (statement) (void)zova_statement_finalize(statement); - return rc; -} - -static int snapshot_copy_digest(zova_statement *statement, int column, - char out[CBM_ZOVA_DIGEST_HEX_SIZE]) { - char *digest = repo_column_text(statement, column); - if (!digest || strlen(digest) != CBM_ZOVA_DIGEST_HEX_SIZE - 1) { - free(digest); - return -1; - } - memcpy(out, digest, CBM_ZOVA_DIGEST_HEX_SIZE); - free(digest); - return 0; -} - -static int snapshot_read_integrity(cbm_zova_repository_t *repo, - cbm_zova_workspace_snapshot_t *snapshot) { - zova_statement *statement = NULL; - int rc = repo_prepare(repo, - "SELECT graph_nodes,graph_edges,metadata_nodes,metadata_edges," - "metadata_topology_edges,fts_rows,node_vector_rows,token_vector_rows," - "node_vectors,token_vectors,metadata_sha256,fts_sha256,topology_sha256," - "node_vector_sha256,token_vector_sha256 FROM cbm_generation_integrity_v2 " - "WHERE workspace_key=?1 AND generation=?2", &statement); - if (rc == 0) rc = repo_bind_i64(statement, 1, repo->workspace_key); - if (rc == 0) rc = repo_bind_i64(statement, 2, snapshot->generation); - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0 && (repo_step(statement, &step) != 0 || step != ZOVA_STEP_ROW)) rc = -1; - cbm_zova_workspace_generation_result_t *integrity = &snapshot->integrity; - if (rc == 0) { - snprintf(integrity->workspace_id, sizeof(integrity->workspace_id), "%s", - repo->workspace_id); - integrity->generation = snapshot->generation; - integrity->graph_nodes = (uint64_t)repo_column_i64(statement, 0); - integrity->graph_edges = (uint64_t)repo_column_i64(statement, 1); - integrity->metadata_nodes = (uint64_t)repo_column_i64(statement, 2); - integrity->metadata_edges = (uint64_t)repo_column_i64(statement, 3); - integrity->metadata_topology_edges = (uint64_t)repo_column_i64(statement, 4); - integrity->fts_rows = (uint64_t)repo_column_i64(statement, 5); - integrity->node_vector_rows = (uint64_t)repo_column_i64(statement, 6); - integrity->token_vector_rows = (uint64_t)repo_column_i64(statement, 7); - integrity->node_vectors = (uint64_t)repo_column_i64(statement, 8); - integrity->token_vectors = (uint64_t)repo_column_i64(statement, 9); - if (snapshot_copy_digest(statement, 10, integrity->metadata_sha256) != 0 || - snapshot_copy_digest(statement, 11, integrity->fts_sha256) != 0 || - snapshot_copy_digest(statement, 12, integrity->topology_sha256) != 0 || - snapshot_copy_digest(statement, 13, integrity->node_vector_sha256) != 0 || - snapshot_copy_digest(statement, 14, integrity->token_vector_sha256) != 0) rc = -1; - } - if (rc == 0 && (repo_step(statement, &step) != 0 || step != ZOVA_STEP_DONE)) rc = -1; - if (statement) (void)zova_statement_finalize(statement); - return rc; -} - -static int snapshot_verify_native(cbm_zova_repository_t *repo, - const cbm_zova_workspace_snapshot_t *snapshot) { - char graph_name[CBM_ZOVA_WORKSPACE_ID_MAX + 32]; - char node_collection[CBM_ZOVA_WORKSPACE_ID_MAX + 128]; - char token_collection[CBM_ZOVA_WORKSPACE_ID_MAX + 128]; - if (cbm_zova_workspace_graph_name(repo->workspace_id, graph_name, sizeof(graph_name)) != 0 || - cbm_zova_workspace_node_vector_collection_name( - repo->workspace_id, snapshot->model_fingerprint, snapshot->vector_dimensions, - node_collection, sizeof(node_collection)) != 0 || - cbm_zova_workspace_token_vector_collection_name( - repo->workspace_id, snapshot->model_fingerprint, snapshot->vector_dimensions, - token_collection, sizeof(token_collection)) != 0) return -1; - zova_graph_info graph = {0}; - zova_vector_collection_info nodes = {0}; - zova_vector_collection_info tokens = {0}; - int rc = zova_graph_info_get(&(zova_graph_info_get_request){ - .db = repo->db, .name = graph_name, .out_info = &graph}) == ZOVA_OK && - zova_vector_collection_info_get(&(zova_vector_collection_info_get_request){ - .db = repo->db, .name = node_collection, .out_info = &nodes}) == ZOVA_OK && - zova_vector_collection_info_get(&(zova_vector_collection_info_get_request){ - .db = repo->db, .name = token_collection, .out_info = &tokens}) == ZOVA_OK && - graph.node_count == snapshot->integrity.graph_nodes && - graph.edge_count == snapshot->integrity.graph_edges && - nodes.vector_count == snapshot->integrity.node_vectors && - tokens.vector_count == snapshot->integrity.token_vectors - ? 0 : -1; - zova_graph_info_free(&graph); - zova_vector_collection_info_free(&nodes); - zova_vector_collection_info_free(&tokens); - return rc; -} - -static int repository_export_snapshot(const char *path, const char *workspace_id, - cbm_zova_workspace_snapshot_t *out, - cbm_zova_snapshot_components_t components) { - if (!out) return -1; - memset(out, 0, sizeof(*out)); - if (!path || !path[0] || cbm_zova_workspace_id_validate(workspace_id) != 0) return -1; - cbm_zova_workspace_snapshot_t snapshot = {0}; - cbm_zova_repository_t repo = {0}; - snprintf(repo.workspace_id, sizeof(repo.workspace_id), "%s", workspace_id); - snprintf(snapshot.workspace_id, sizeof(snapshot.workspace_id), "%s", workspace_id); - zova_message error = {0}; - struct timespec base_started = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &base_started); - int rc = zova_database_open_with_options(&(zova_database_open_options_request){ - .path = path, .flags = ZOVA_OPEN_READ_ONLY, .busy_timeout_ms = 5000, - .out_db = &repo.db, .out_error_message = &error}) == ZOVA_OK && repo.db - ? 0 : -1; - zova_message_free(&error); - snapshot.metrics.open_ms = repo_elapsed_ms(base_started); - if (rc == 0) snapshot.metrics.base_phase_mask |= CBM_ZOVA_SNAPSHOT_BASE_PHASE_OPEN; - bool transaction_started = false; - const char *phase = "open"; - if (rc == 0 && (cbm_zova_register_sql_functions(repo.db) != 0 || - zova_database_exec(&(zova_database_exec_request){ - .db = repo.db, .sql = "BEGIN"}) != ZOVA_OK)) rc = -1; - else if (rc == 0) transaction_started = true; - char **stable_ids = NULL; - snapshot_node_key_ref_t *node_keys = NULL; - struct timespec phase_started = {0}; - if (rc == 0) { - phase = "header"; - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_started); - rc = snapshot_read_header(&repo, &snapshot); - snapshot.metrics.header_ms = repo_elapsed_ms(phase_started); - if (rc == 0) snapshot.metrics.base_phase_mask |= CBM_ZOVA_SNAPSHOT_BASE_PHASE_HEADER; - } - if (rc == 0) { - phase = "integrity"; - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_started); - rc = snapshot_read_integrity(&repo, &snapshot); - snapshot.metrics.integrity_ms = repo_elapsed_ms(phase_started); - if (rc == 0) snapshot.metrics.base_phase_mask |= CBM_ZOVA_SNAPSHOT_BASE_PHASE_INTEGRITY; - } - if (rc == 0) { phase = "nodes"; rc = snapshot_read_nodes(&repo, &snapshot, &stable_ids, &node_keys); } - if (rc == 0) { phase = "edges"; rc = snapshot_read_edges(&repo, &snapshot, node_keys); } - if (rc == 0) { phase = "hashes_summary"; rc = snapshot_read_hashes_summary(&repo, &snapshot); } - struct timespec optional_started = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &optional_started); - if (rc == 0 && (components & CBM_ZOVA_SNAPSHOT_COMPONENT_TOPOLOGY)) - rc = snapshot_build_topology(&snapshot, &snapshot); - if (rc == 0 && (components & CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS)) - rc = snapshot_read_node_vectors(&repo, &snapshot, &snapshot); - if (rc == 0 && (components & CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS)) - rc = snapshot_read_token_vectors(&repo, &snapshot, &snapshot); - snapshot.metrics.optional_ms = components == CBM_ZOVA_SNAPSHOT_COMPONENT_NONE - ? 0.0 : repo_elapsed_ms(optional_started); - if (rc == 0 && (snapshot.node_count != (int)snapshot.integrity.metadata_nodes || - snapshot.edge_count != (int)snapshot.integrity.metadata_edges)) - rc = -1; - if (rc == 0 && (components & CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS) && - snapshot.node_vector_count != (int)snapshot.integrity.node_vectors) - rc = -1; - if (rc == 0 && (components & CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS) && - snapshot.token_vector_count != (int)snapshot.integrity.token_vectors) - rc = -1; - if (rc == 0 && components == CBM_ZOVA_SNAPSHOT_COMPONENT_ALL) - rc = snapshot_verify_native(&repo, &snapshot); - if (rc == 0) { - snapshot.hydrated_components = components; - snapshot.metrics.hydrated_components = snapshot.hydrated_components; - snapshot.metrics.topology_rows = (uint64_t)snapshot.topology_edge_count; - snapshot.metrics.node_vector_rows = (uint64_t)snapshot.node_vector_count; - snapshot.metrics.token_vector_rows = (uint64_t)snapshot.token_vector_count; - } - free(node_keys); - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_started); - if (transaction_started) { - zova_status tx_status = zova_database_exec(&(zova_database_exec_request){ - .db = repo.db, .sql = rc == 0 ? "COMMIT" : "ROLLBACK"}); - if (rc == 0 && tx_status != ZOVA_OK) rc = -1; - } - if (repo.db) (void)zova_database_close(repo.db); - snapshot.metrics.close_ms = repo_elapsed_ms(phase_started); - if (rc == 0) snapshot.metrics.base_phase_mask |= CBM_ZOVA_SNAPSHOT_BASE_PHASE_CLOSE; - snapshot.metrics.base_ms = repo_elapsed_ms(base_started); - if (rc != 0) { - fprintf(stderr, "zova snapshot export failed phase=%s\n", phase); - cbm_zova_workspace_snapshot_free(&snapshot); - return -1; - } - *out = snapshot; - return 0; -} - -int cbm_zova_repository_export_snapshot(const char *path, const char *workspace_id, - cbm_zova_workspace_snapshot_t *out) { - return repository_export_snapshot(path, workspace_id, out, - CBM_ZOVA_SNAPSHOT_COMPONENT_ALL); -} - -int cbm_zova_repository_export_incremental_snapshot( - const char *path, const char *workspace_id, cbm_zova_workspace_snapshot_t *out) { - return repository_export_snapshot(path, workspace_id, out, - CBM_ZOVA_SNAPSHOT_COMPONENT_NONE); -} - -typedef struct { - uint64_t source_ordinal; - int index; -} snapshot_graph_node_order_t; - -static int snapshot_graph_node_order_compare(const void *left_ptr, const void *right_ptr) { - const snapshot_graph_node_order_t *left = left_ptr; - const snapshot_graph_node_order_t *right = right_ptr; - if (left->source_ordinal != right->source_ordinal) - return left->source_ordinal < right->source_ordinal ? -1 : 1; - return (left->index > right->index) - (left->index < right->index); -} - -static int snapshot_load_graph(const cbm_zova_workspace_snapshot_t *snapshot, - cbm_gbuf_t *graph) { - if (!snapshot || !graph || - cbm_gbuf_reserve(graph, snapshot->node_count, snapshot->edge_count) != 0) - return -1; - int64_t *ids = snapshot->node_count - ? calloc((size_t)snapshot->node_count + 1, sizeof(*ids)) : NULL; - snapshot_graph_node_order_t *order = snapshot->node_count - ? calloc((size_t)snapshot->node_count, sizeof(*order)) : NULL; - if (snapshot->node_count && (!ids || !order || !snapshot->node_source_ordinals)) { - free(ids); free(order); return -1; - } - for (int i = 0; i < snapshot->node_count; i++) - order[i] = (snapshot_graph_node_order_t){ - .source_ordinal = snapshot->node_source_ordinals[i], .index = i}; - if (snapshot->node_count > 1) - qsort(order, (size_t)snapshot->node_count, sizeof(*order), - snapshot_graph_node_order_compare); - int rc = 0; - for (int i = 0; rc == 0 && i < snapshot->node_count; i++) { - const CBMDumpNode *node = &snapshot->nodes[order[i].index]; - if (node->id <= 0 || node->id > snapshot->node_count || ids[node->id] != 0) { - rc = -1; break; - } - ids[node->id] = cbm_gbuf_upsert_node( - graph, node->label, node->name, node->qualified_name, node->file_path, - node->start_line, node->end_line, node->properties); - if (ids[node->id] <= 0) rc = -1; - } - for (int i = 0; rc == 0 && i < snapshot->edge_count; i++) { - const CBMDumpEdge *edge = &snapshot->edges[i]; - int64_t source = edge->source_id > 0 && edge->source_id <= snapshot->node_count - ? ids[edge->source_id] : 0; - int64_t target = edge->target_id > 0 && edge->target_id <= snapshot->node_count - ? ids[edge->target_id] : 0; - if (source <= 0 || target <= 0 || - cbm_gbuf_insert_edge(graph, source, target, edge->type, edge->properties) <= 0) - rc = -1; - } - free(ids); - free(order); - return rc; -} - -int cbm_zova_repository_export_incremental_snapshot_to_graph( - const char *path, const char *workspace_id, cbm_zova_workspace_snapshot_t *out, - cbm_gbuf_t *graph) { - if (!graph || cbm_zova_repository_export_incremental_snapshot(path, workspace_id, out) != 0) - return -1; - struct timespec started = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - if (snapshot_load_graph(out, graph) != 0) { - cbm_zova_workspace_snapshot_free(out); - return -1; - } - out->metrics.graph_buffer_ms = repo_elapsed_ms(started); - return 0; -} - -static int snapshot_integrity_matches(const cbm_zova_workspace_snapshot_t *left, - const cbm_zova_workspace_snapshot_t *right) { - const cbm_zova_workspace_generation_result_t *a = &left->integrity; - const cbm_zova_workspace_generation_result_t *b = &right->integrity; - return left->generation == right->generation && - strcmp(left->workspace_id, right->workspace_id) == 0 && - a->metadata_nodes == b->metadata_nodes && a->metadata_edges == b->metadata_edges && - a->metadata_topology_edges == b->metadata_topology_edges && - a->node_vectors == b->node_vectors && a->token_vectors == b->token_vectors && - strcmp(a->metadata_sha256, b->metadata_sha256) == 0 && - strcmp(a->fts_sha256, b->fts_sha256) == 0 && - strcmp(a->topology_sha256, b->topology_sha256) == 0 && - strcmp(a->node_vector_sha256, b->node_vector_sha256) == 0 && - strcmp(a->token_vector_sha256, b->token_vector_sha256) == 0; -} - -int cbm_zova_repository_hydrate_incremental_components( - const char *path, const char *workspace_id, int64_t expected_generation, - cbm_zova_snapshot_components_t requested, cbm_zova_workspace_snapshot_t *snapshot) { - if (!path || !path[0] || !snapshot || - cbm_zova_workspace_id_validate(workspace_id) != 0 || - strcmp(workspace_id, snapshot->workspace_id) != 0 || - requested == CBM_ZOVA_SNAPSHOT_COMPONENT_NONE || - (requested & ~CBM_ZOVA_SNAPSHOT_COMPONENT_ALL) != 0) - return CBM_ZOVA_SNAPSHOT_ERROR; - if (expected_generation <= 0 || expected_generation != snapshot->generation) - return CBM_ZOVA_SNAPSHOT_STALE; - cbm_zova_snapshot_components_t pending = requested & ~snapshot->hydrated_components; - if (pending == CBM_ZOVA_SNAPSHOT_COMPONENT_NONE) return CBM_ZOVA_SNAPSHOT_OK; - - struct timespec started = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - cbm_zova_workspace_snapshot_t hydrated = {0}; - int rc = 0; - if (pending & CBM_ZOVA_SNAPSHOT_COMPONENT_TOPOLOGY) - rc = snapshot_build_topology(snapshot, &hydrated); - - cbm_zova_repository_t repo = {0}; - cbm_zova_workspace_snapshot_t current = {0}; - bool transaction_started = false; - bool needs_database = (pending & (CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS | - CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS)) != 0; - if (rc == 0 && needs_database) { - snprintf(repo.workspace_id, sizeof(repo.workspace_id), "%s", workspace_id); - snprintf(current.workspace_id, sizeof(current.workspace_id), "%s", workspace_id); - zova_message error = {0}; - rc = zova_database_open_with_options(&(zova_database_open_options_request){ - .path = path, .flags = ZOVA_OPEN_READ_ONLY, .busy_timeout_ms = 5000, - .out_db = &repo.db, .out_error_message = &error}) == ZOVA_OK && repo.db - ? 0 : -1; - zova_message_free(&error); - if (rc == 0 && (cbm_zova_register_sql_functions(repo.db) != 0 || - zova_database_exec(&(zova_database_exec_request){ - .db = repo.db, .sql = "PRAGMA query_only=ON;BEGIN"}) != ZOVA_OK)) - rc = -1; - else if (rc == 0) transaction_started = true; - if (rc == 0) rc = snapshot_read_header(&repo, ¤t); - if (rc == 0) rc = snapshot_read_integrity(&repo, ¤t); - if (rc == 0 && !snapshot_integrity_matches(snapshot, ¤t)) rc = -2; - if (rc == 0 && (pending & CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS)) - rc = snapshot_read_node_vectors(&repo, snapshot, &hydrated); - if (rc == 0 && (pending & CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS)) - rc = snapshot_read_token_vectors(&repo, snapshot, &hydrated); - if (transaction_started) { - zova_status tx_status = zova_database_exec(&(zova_database_exec_request){ - .db = repo.db, .sql = rc == 0 ? "COMMIT" : "ROLLBACK"}); - if (rc == 0 && tx_status != ZOVA_OK) rc = -1; - } - if (repo.db) (void)zova_database_close(repo.db); - } - cbm_zova_workspace_snapshot_free(¤t); - if (rc != 0) { - cbm_zova_workspace_snapshot_free(&hydrated); - return rc == -2 ? CBM_ZOVA_SNAPSHOT_STALE : CBM_ZOVA_SNAPSHOT_ERROR; - } - - if (pending & CBM_ZOVA_SNAPSHOT_COMPONENT_TOPOLOGY) { - snapshot->topology_edges = hydrated.topology_edges; - snapshot->topology_edge_count = hydrated.topology_edge_count; - hydrated.topology_edges = NULL; - hydrated.topology_edge_count = 0; - } - if (pending & CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS) { - snapshot->node_vectors = hydrated.node_vectors; - snapshot->node_vector_ids = hydrated.node_vector_ids; - snapshot->node_vector_count = hydrated.node_vector_count; - hydrated.node_vectors = NULL; - hydrated.node_vector_ids = NULL; - hydrated.node_vector_count = 0; - } - if (pending & CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS) { - snapshot->token_vectors = hydrated.token_vectors; - snapshot->token_vector_ids = hydrated.token_vector_ids; - snapshot->token_vector_count = hydrated.token_vector_count; - hydrated.token_vectors = NULL; - hydrated.token_vector_ids = NULL; - hydrated.token_vector_count = 0; - } - cbm_zova_workspace_snapshot_free(&hydrated); - snapshot->hydrated_components |= pending; - snapshot->metrics.hydrated_components = snapshot->hydrated_components; - snapshot->metrics.topology_rows = (uint64_t)snapshot->topology_edge_count; - snapshot->metrics.node_vector_rows = (uint64_t)snapshot->node_vector_count; - snapshot->metrics.token_vector_rows = (uint64_t)snapshot->token_vector_count; - snapshot->metrics.optional_ms += repo_elapsed_ms(started); - return CBM_ZOVA_SNAPSHOT_OK; -} - -#else -struct cbm_zova_repository { int unused; }; -struct cbm_zova_catalog { int unused; }; -cbm_zova_repository_t *cbm_zova_repository_open(const char *p,const char *n){(void)p;(void)n;return NULL;} -void cbm_zova_repository_close(cbm_zova_repository_t *r){(void)r;} -const char *cbm_zova_repository_workspace_id(const cbm_zova_repository_t *r){(void)r;return "";} -int cbm_zova_repository_get_project(cbm_zova_repository_t*r,const char*w,cbm_project_t*o){(void)r;(void)w;(void)o;return CBM_STORE_ERR;} -int cbm_zova_repository_find_node_by_qn(cbm_zova_repository_t*r,const char*w,const char*q,cbm_node_t*o){(void)r;(void)w;(void)q;(void)o;return CBM_STORE_ERR;} -int cbm_zova_repository_find_node_by_stable_id(cbm_zova_repository_t*r,const char*w,const char*s,cbm_node_t*o){(void)r;(void)w;(void)s;(void)o;return CBM_STORE_ERR;} -int cbm_zova_repository_find_nodes_by_stable_ids(cbm_zova_repository_t*r,const char*w,const char*const*s,int c,cbm_node_t**o){(void)r;(void)w;(void)s;(void)c;(void)o;return CBM_STORE_ERR;} -int cbm_zova_repository_find_nodes_by_keys(cbm_zova_repository_t*r,const char*w,const int64_t*k,int c,cbm_node_t**o){(void)r;(void)w;(void)k;(void)c;(void)o;return CBM_STORE_ERR;} -int cbm_zova_repository_find_node_by_numeric_id(cbm_zova_repository_t*r,const char*w,int64_t n,cbm_node_t*o){(void)r;(void)w;(void)n;(void)o;return CBM_STORE_ERR;} -int cbm_zova_repository_find_nodes_by_file_overlap(cbm_zova_repository_t*r,const char*w,const char*f,int s,int e,cbm_node_t**o,int*c){(void)r;(void)w;(void)f;(void)s;(void)e;(void)o;(void)c;return CBM_STORE_ERR;} -int cbm_zova_repository_stable_id_for_numeric_id(cbm_zova_repository_t*r,const char*w,int64_t n,char*o,size_t z){(void)r;(void)w;(void)n;if(o&&z)o[0]='\0';return CBM_STORE_ERR;} -int cbm_zova_repository_call_neighbor_keys(cbm_zova_repository_t*r,const char*w,const char*s,bool i,int64_t**o,int*c){(void)r;(void)w;(void)s;(void)i;(void)o;(void)c;return CBM_STORE_ERR;} -int cbm_zova_repository_find_edges(cbm_zova_repository_t*r,const char*w,const char*s,const char*d,cbm_edge_t**o,int*c){(void)r;(void)w;(void)s;(void)d;(void)o;(void)c;return CBM_STORE_ERR;} -int cbm_zova_repository_visit_edges(cbm_zova_repository_t*r,const char*w,const char*t,cbm_zova_repository_edge_visitor_t v,void*c){(void)r;(void)w;(void)t;(void)v;(void)c;return CBM_STORE_ERR;} -int cbm_zova_repository_find_edges_by_type(cbm_zova_repository_t*r,const char*w,const char*t,cbm_edge_t**o,int*c){(void)r;(void)w;(void)t;(void)o;(void)c;return CBM_STORE_ERR;} -int cbm_zova_repository_search(cbm_zova_repository_t*r,const char*w,const cbm_search_params_t*p,cbm_search_output_t*o){(void)r;(void)w;(void)p;(void)o;return CBM_STORE_ERR;} -int cbm_zova_repository_search_fts(cbm_zova_repository_t*r,const char*w,const char*q,const char*f,int l,int x,cbm_search_output_t*o){(void)r;(void)w;(void)q;(void)f;(void)l;(void)x;(void)o;return CBM_STORE_ERR;} -int cbm_zova_repository_index_status(cbm_zova_repository_t*r,const char*w,int64_t*g,char**i){(void)r;(void)w;(void)g;(void)i;return CBM_STORE_ERR;} -int cbm_zova_repository_counts(cbm_zova_repository_t*r,const char*w,int*n,int*e){(void)r;(void)w;(void)n;(void)e;return CBM_STORE_ERR;} -int cbm_zova_repository_graph_identities(cbm_zova_repository_t*r,const char*w,uint64_t mn,uint64_t me,cbm_zova_identity_node_t**n,int*nc,cbm_zova_identity_edge_t**e,int*ec){(void)r;(void)w;(void)mn;(void)me;if(n)*n=NULL;if(nc)*nc=0;if(e)*e=NULL;if(ec)*ec=0;return CBM_STORE_ERR;} -void cbm_zova_identities_free(cbm_zova_identity_node_t*n,int nc,cbm_zova_identity_edge_t*e,int ec){(void)nc;(void)ec;free(n);free(e);} -int cbm_zova_repository_file_coverage(cbm_zova_repository_t*r,const char*w,const char*p,cbm_zova_file_coverage_t**o,int*c,char**rp,int64_t*g,bool*rec){(void)r;(void)w;(void)p;(void)g;if(rec)*rec=false;if(o)*o=NULL;if(c)*c=0;if(rp)*rp=NULL;return CBM_STORE_ERR;} -void cbm_zova_file_coverage_free(cbm_zova_file_coverage_t*r,int c){(void)r;(void)c;} -int cbm_zova_repository_project_summary(cbm_zova_repository_t*r,const char*w,cbm_project_summary_export_t*o){(void)r;(void)w;(void)o;return CBM_STORE_ERR;} -int cbm_zova_repository_export_snapshot(const char*p,const char*w,cbm_zova_workspace_snapshot_t*o){(void)p;(void)w;if(o)memset(o,0,sizeof(*o));return -1;} -int cbm_zova_repository_export_incremental_snapshot(const char*p,const char*w,cbm_zova_workspace_snapshot_t*o){(void)p;(void)w;if(o)memset(o,0,sizeof(*o));return -1;} -int cbm_zova_repository_export_incremental_snapshot_to_graph(const char*p,const char*w,cbm_zova_workspace_snapshot_t*o,cbm_gbuf_t*g){(void)p;(void)w;(void)g;if(o)memset(o,0,sizeof(*o));return -1;} -int cbm_zova_repository_hydrate_incremental_components(const char*p,const char*w,int64_t g,cbm_zova_snapshot_components_t c,cbm_zova_workspace_snapshot_t*s){(void)p;(void)w;(void)g;(void)c;(void)s;return CBM_ZOVA_SNAPSHOT_ERROR;} -int cbm_zova_workspace_snapshot_format_edge_id(const cbm_zova_workspace_snapshot_t*s,int i,char*o,size_t z){(void)s;(void)i;if(o&&z)o[0]='\0';return -1;} -void cbm_zova_workspace_snapshot_free(cbm_zova_workspace_snapshot_t*s){if(s)memset(s,0,sizeof(*s));} -cbm_zova_catalog_t *cbm_zova_catalog_open(const char*p){(void)p;return NULL;} -void cbm_zova_catalog_close(cbm_zova_catalog_t*c){(void)c;} -int cbm_zova_catalog_list(cbm_zova_catalog_t*c,cbm_zova_catalog_scope_t*o){(void)c;if(o)memset(o,0,sizeof(*o));return CBM_STORE_ERR;} -const char *cbm_zova_catalog_error(const cbm_zova_catalog_t*c){(void)c;return "";} -int cbm_zova_catalog_resolve(cbm_zova_catalog_t*c,const char*const*s,int n,bool w,cbm_zova_catalog_scope_t*o){(void)c;(void)s;(void)n;(void)w;if(o)memset(o,0,sizeof(*o));return CBM_STORE_ERR;} -int cbm_zova_catalog_search_fts(cbm_zova_catalog_t*c,const cbm_zova_catalog_scope_t*s,const char*q,const char*f,int l,int x,cbm_search_output_t*o){(void)c;(void)s;(void)q;(void)f;(void)l;(void)x;if(o)memset(o,0,sizeof(*o));return CBM_STORE_ERR;} -int cbm_zova_catalog_search(cbm_zova_catalog_t*c,const cbm_zova_catalog_scope_t*s,const cbm_search_params_t*p,cbm_search_output_t*o){(void)c;(void)s;(void)p;if(o)memset(o,0,sizeof(*o));return CBM_STORE_ERR;} -int cbm_zova_catalog_search_semantic(cbm_zova_catalog_t*c,const char*p,const cbm_zova_catalog_scope_t*s,const char**k,int n,int l,int x,cbm_vector_result_t**o,int*z,int*t){(void)c;(void)p;(void)s;(void)k;(void)n;(void)l;(void)x;if(o)*o=NULL;if(z)*z=0;if(t)*t=0;return CBM_STORE_ERR;} -void cbm_zova_catalog_scope_free(cbm_zova_catalog_scope_t*s){if(s)memset(s,0,sizeof(*s));} -#endif diff --git a/src/zova/cbm_zova_repository.zig b/src/zova/cbm_zova_repository.zig new file mode 100644 index 00000000..89b8b3af --- /dev/null +++ b/src/zova/cbm_zova_repository.zig @@ -0,0 +1,3619 @@ +//! cbm_zova_repository.zig — Read access to a cbm.zova file: one workspace +//! (nodes, edges, search, file coverage), the catalog of every workspace in +//! the file, and whole-workspace snapshots for incremental indexing. +//! +//! Implements src/zova/cbm_zova_repository.h. Without Zova every function +//! clears its outputs and fails. + +const std = @import("std"); +const c = @import("c"); +const interop = @import("../foundation/interop.zig"); + +const Str = interop.Str; +const CStr = interop.CStr; +const span = std.mem.span; + +const with_zova = c.CBM_WITH_ZOVA != 0; + +// Zova SDK types. They exist only with Zova; nothing reaches them without it. +const Db = if (with_zova) c.zova_database else anyopaque; +const Stmt = if (with_zova) c.zova_statement else anyopaque; +const KeyedNodes = c.zova_graph_keyed_node_results; +const ScanResults = c.zova_graph_scan_results; +const ScanCursor = c.zova_graph_scan_cursor; + +const Node = c.cbm_node_t; +const Edge = c.cbm_edge_t; +const SearchResult = c.cbm_search_result_t; +const SearchOutput = c.cbm_search_output_t; +const SearchParams = c.cbm_search_params_t; +const CatalogProject = c.cbm_zova_catalog_project_t; +const Scope = c.cbm_zova_catalog_scope_t; +const IdentityNode = c.cbm_zova_identity_node_t; +const IdentityEdge = c.cbm_zova_identity_edge_t; +const FileCoverage = c.cbm_zova_file_coverage_t; +const Snapshot = c.cbm_zova_workspace_snapshot_t; +const Components = c.cbm_zova_snapshot_components_t; + +const OK: c_int = c.CBM_STORE_OK; +const ERR: c_int = c.CBM_STORE_ERR; +const NOT_FOUND: c_int = c.CBM_STORE_NOT_FOUND; +const SCAN_LIMIT: c_int = c.CBM_STORE_SCAN_LIMIT; + +const ID_MAX: usize = c.CBM_ZOVA_WORKSPACE_ID_MAX; +const PROJECT_CAP: usize = c.CBM_SZ_4K; +const INT_MAX: c_int = std.math.maxInt(c_int); + +const GRAPH_SCAN_PAGE: usize = 1024; +const EDGE_SCAN_PAGE: usize = 4096; +const GRAPH_NAME_CAP: usize = ID_MAX + 64; +const STRING_ARENA_CHUNK: usize = 64 * 1024; + +const Error = error{Failed}; + +const NumericIdEntry = extern struct { + numeric_id: i64, + order: usize, + node_key: i64, + stable_id: [*:0]u8, +}; + +const Repository = extern struct { + db: ?*Db = null, + workspace_id: [ID_MAX]u8 = @splat(0), + project: [PROJECT_CAP]u8 = @splat(0), + workspace_key: i64 = 0, + generation: i64 = 0, + /// Graph nodes sorted by numeric id (the hash of the stable id), built on + /// the first id lookup so per-node reads do not rescan the whole graph. + numeric_ids: [*c]NumericIdEntry = null, + numeric_id_count: usize = 0, + numeric_id_strings: ?*StringArena = null, + + fn workspaceId(repo: *const Repository) Str { + return @ptrCast(&repo.workspace_id); + } +}; + +const Catalog = extern struct { + db: ?*Db, + projects: [*c]CatalogProject, + count: c_int, + transaction_open: bool, + error_text: [512]u8, + + fn items(catalog: *const Catalog) []CatalogProject { + return many(catalog.projects, catalog.count); + } +}; + +fn Span(comptime P: type) type { + const info = @typeInfo(P).pointer; + return if (info.attrs.@"const") []const info.child else []info.child; +} + +/// The items of a (C pointer, count) array; empty for NULL or count <= 0. +fn many(items: anytype, count: anytype) Span(@TypeOf(items)) { + if (items == null or count <= 0) return &.{}; + return items[0..@intCast(count)]; +} + +/// free() for the `const` pointers of the store structs, which the caller of +/// this module owns. +fn freeConst(p: anytype) void { + c.free(@ptrCast(@constCast(p))); +} + +/// The string when it is neither NULL nor empty. +fn nonEmpty(text: ?Str) ?Str { + const value = text orelse return null; + return if (value[0] != 0) value else null; +} + +fn cast(comptime T: type, p: ?*const anyopaque) *const T { + return @ptrCast(@alignCast(p)); +} + +// ── String arena ──────────────────────────────────────────────── + +const StringArenaChunk = extern struct { + next: ?*StringArenaChunk, + used: usize, + capacity: usize, + + /// The bytes that follow the header in the same allocation. + fn data(chunk: *StringArenaChunk) [*]u8 { + return @as([*]u8, @ptrCast(chunk)) + @sizeOf(StringArenaChunk); + } +}; + +const StringArena = extern struct { + head: ?*StringArenaChunk, + chunk_count: u64, + bytes_used: u64, +}; + +fn repoStringArenaFree(maybe_arena: ?*StringArena) void { + const arena = maybe_arena orelse return; + var chunk = arena.head; + while (chunk) |current| { + chunk = current.next; + c.free(current); + } + c.free(arena); +} + +fn repoStringArenaDup(maybe_arena: ?*StringArena, text: [*c]const u8, len: usize) ?[*:0]u8 { + const arena = maybe_arena orelse return null; + const needed = len + 1; + var chunk = arena.head; + if (chunk == null or chunk.?.capacity - chunk.?.used < needed) { + const capacity = @max(needed, STRING_ARENA_CHUNK); + const fresh: *StringArenaChunk = @ptrCast(@alignCast(c.malloc(@sizeOf(StringArenaChunk) + capacity) orelse return null)); + fresh.* = .{ .next = arena.head, .used = 0, .capacity = capacity }; + arena.head = fresh; + arena.chunk_count += 1; + chunk = fresh; + } + const current = chunk.?; + const copy = current.data() + current.used; + if (len != 0) @memcpy(copy[0..len], text[0..len]); + copy[len] = 0; + current.used += needed; + arena.bytes_used += needed; + return @ptrCast(copy); +} + +/// A malloc'd NUL-terminated copy of text[0..len]; NULL on OOM. +fn repoDup(text: [*c]const u8, len: usize) [*c]u8 { + const copy = interop.mallocN(u8, len + 1); + if (copy == null) return null; + if (len != 0) @memcpy(copy[0..len], text[0..len]); + copy[len] = 0; + return copy; +} + +/// A malloc'd copy of a NUL-terminated string; NULL on OOM. +fn repoDupZ(text: CStr) [*c]u8 { + return repoDup(text, c.strlen(text)); +} + +/// A count narrowed the way a C `(int)` cast narrows it. +fn narrowInt(value: u64) c_int { + return @bitCast(@as(c_uint, @truncate(value))); +} + +// ── Zova SDK calls ────────────────────────────────────────────── + +/// Opens `path` read-only with the 5 s busy timeout every reader here uses. +fn repoOpenReadOnly(path: Str) ?*Db { + var db: ?*Db = null; + var message = std.mem.zeroes(c.zova_message); + const status = c.zova_database_open_with_options(&c.zova_database_open_options_request{ + .path = path, + .flags = c.ZOVA_OPEN_READ_ONLY, + .busy_timeout_ms = 5000, + .out_db = &db, + .out_error_message = &message, + }); + c.zova_message_free(&message); + if (status == c.ZOVA_OK) return db; + if (db) |opened| _ = c.zova_database_close(opened); + return null; +} + +fn repoExec(db: ?*Db, sql: Str) bool { + return c.zova_database_exec(&c.zova_database_exec_request{ .db = db, .sql = sql }) == c.ZOVA_OK; +} + +fn repoPrepare(db: ?*Db, sql: Str) ?*Stmt { + var statement: ?*Stmt = null; + const status = c.zova_database_prepare(&c.zova_database_prepare_request{ + .db = db, + .sql = sql, + .out_statement = &statement, + }); + if (status == c.ZOVA_OK) return statement; + repoFinalize(statement); + return null; +} + +fn repoFinalize(statement: ?*Stmt) void { + if (statement) |open| _ = c.zova_statement_finalize(open); +} + +fn repoBindText(statement: ?*Stmt, index: c_int, value: CStr) bool { + return c.zova_statement_bind_text(&c.zova_statement_bind_text_request{ + .statement = statement, + .index = index, + .data = value, + .len = c.strlen(value), + }) == c.ZOVA_OK; +} + +fn repoBindI64(statement: ?*Stmt, index: c_int, value: i64) bool { + return c.zova_statement_bind_int64(&c.zova_statement_bind_int64_request{ + .statement = statement, + .index = index, + .value = value, + }) == c.ZOVA_OK; +} + +fn repoBindNull(statement: ?*Stmt, index: c_int) bool { + return c.zova_statement_bind_null(&c.zova_statement_bind_null_request{ + .statement = statement, + .index = index, + }) == c.ZOVA_OK; +} + +fn repoStep(statement: ?*Stmt, out: *c.zova_step_result) bool { + return c.zova_statement_step(&c.zova_statement_step_request{ + .statement = statement, + .out_result = out, + }) == c.ZOVA_OK; +} + +/// Steps once and says whether the statement produced a row; an error when +/// the step itself failed. +fn repoNextRow(statement: ?*Stmt) Error!bool { + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoStep(statement, &step)) return error.Failed; + return step != c.ZOVA_STEP_DONE; +} + +/// A malloc'd copy of the column, "" for SQL NULL; NULL when the read or the +/// copy failed. +fn repoColumnText(statement: ?*Stmt, index: c_int) [*c]u8 { + var text = std.mem.zeroes(c.zova_text); + if (c.zova_statement_column_text(&c.zova_statement_column_text_request{ + .statement = statement, + .index = index, + .out_text = &text, + }) != c.ZOVA_OK) return null; + defer c.zova_text_free(&text); + return repoDup(text.data, if (text.data != null) text.len else 0); +} + +fn repoColumnI64(statement: ?*Stmt, index: c_int) i64 { + var value: i64 = 0; + _ = c.zova_statement_column_int64(&c.zova_statement_column_int64_request{ + .statement = statement, + .index = index, + .out_value = &value, + }); + return value; +} + +/// The column narrowed the way a C `(int)` cast narrows it. +fn repoColumnInt(statement: ?*Stmt, index: c_int) c_int { + return @truncate(repoColumnI64(statement, index)); +} + +fn repoColumnDouble(statement: ?*Stmt, index: c_int) f64 { + var value: f64 = 0.0; + _ = c.zova_statement_column_double(&c.zova_statement_column_double_request{ + .statement = statement, + .index = index, + .out_value = &value, + }); + return value; +} + +// ── Identities ────────────────────────────────────────────────── + +/// FNV-1a 64 over the string; the offset basis for NULL. +fn fnv1a64(value: CStr) u64 { + var hash: u64 = 1469598103934665603; + if (value != null) { + for (span(value)) |byte| hash = (hash ^ byte) *% 1099511628211; + } + return hash; +} + +fn stableNumericId(value: CStr) i64 { + return @intCast(fnv1a64(value) & std.math.maxInt(i64)); +} + +/// Writes "e:v1::<32 hex>" for the edge (source, type, target, +/// local name). False when a part is NULL or `out` is too small. +fn repoEdgeId(workspace_id: CStr, source: CStr, edge_type: CStr, target: CStr, local_name: CStr, out: []u8) bool { + if (workspace_id == null or source == null or edge_type == null or target == null or local_name == null) return false; + var hash: c.cbm_sha256_ctx = undefined; + var digest: [c.CBM_SHA256_DIGEST_LEN]u8 = undefined; + c.cbm_sha256_init(&hash); + for ([_]CStr{ source, edge_type, target, local_name }) |part| { + c.cbm_sha256_update(&hash, part, c.strlen(part)); + c.cbm_sha256_update(&hash, "\x00", 1); + } + c.cbm_sha256_final(&hash, &digest); + const hex = std.fmt.bytesToHex(digest[0..16].*, .lower); + return interop.bprint(out, "e:v1:{s}:{s}", .{ span(workspace_id), &hex }) < out.len; +} + +fn repoGraphName(repo: *const Repository, out: []u8) bool { + return c.cbm_zova_workspace_graph_name(repo.workspaceId(), out.ptr, out.len) == 0; +} + +// ── Numeric id index ──────────────────────────────────────────── + +fn repoNumericIdCompare(left: ?*const anyopaque, right: ?*const anyopaque) callconv(.c) c_int { + const a = cast(NumericIdEntry, left); + const b = cast(NumericIdEntry, right); + if (a.numeric_id != b.numeric_id) return if (a.numeric_id < b.numeric_id) -1 else 1; + return @as(c_int, @intFromBool(a.order > b.order)) - @intFromBool(a.order < b.order); +} + +fn repoNumericIdsClear(repo: *Repository) void { + c.free(repo.numeric_ids); + repoStringArenaFree(repo.numeric_id_strings); + repo.numeric_ids = null; + repo.numeric_id_count = 0; + repo.numeric_id_strings = null; +} + +/// One paged scan of the workspace graph; equal numeric ids keep scan order so +/// the lookup returns the same node the former linear search found first. +fn repoNumericIdsBuild(repo: *Repository) Error!void { + repoNumericIdsClear(repo); + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (!repoGraphName(repo, &graph_name)) return error.Failed; + repo.numeric_id_strings = @as(?*StringArena, interop.callocN(StringArena, 1)) orelse return error.Failed; + errdefer repoNumericIdsClear(repo); + var count: usize = 0; + var cap: usize = 0; + var cursor = std.mem.zeroes(ScanCursor); + while (true) { + var page = std.mem.zeroes(ScanResults); + defer c.zova_graph_scan_results_free(&page); + if (c.zova_graph_scan(&c.zova_graph_scan_request{ + .db = repo.db, + .graph_name = &graph_name, + .node_after = cursor, + .node_limit = GRAPH_SCAN_PAGE, + .edge_limit = 0, + .out_results = &page, + }) != c.ZOVA_OK) return error.Failed; + const nodes = many(page.nodes, page.nodes_len); + for (nodes) |*node| { + if (node.node_id == null) continue; + if (count == cap) { + const next_cap = if (cap != 0) cap * 2 else GRAPH_SCAN_PAGE; + const grown = interop.reallocN(NumericIdEntry, repo.numeric_ids, next_cap); + if (grown == null) return error.Failed; + repo.numeric_ids = grown; + cap = next_cap; + } + const stable = repoStringArenaDup(repo.numeric_id_strings, node.node_id, c.strlen(node.node_id)) orelse return error.Failed; + repo.numeric_ids[count] = .{ + .numeric_id = stableNumericId(stable), + .order = count, + .node_key = node.node_key, + .stable_id = stable, + }; + count += 1; + } + if (nodes.len != 0) { + const last = &nodes[nodes.len - 1]; + cursor = .{ .created_order = last.created_order, .key = last.node_key }; + } + if (page.has_more_nodes == 0) break; + } + if (count != 0) c.qsort(repo.numeric_ids, count, @sizeOf(NumericIdEntry), repoNumericIdCompare); + repo.numeric_id_count = count; +} + +/// The entries with `numeric_id` (equal ids are contiguous); null when the +/// index cannot be built, empty when no node has that id. +fn repoNumericIdsFind(repo: *Repository, numeric_id: i64) ?[]const NumericIdEntry { + if (repo.numeric_id_strings == null) repoNumericIdsBuild(repo) catch return null; + const entries = many(repo.numeric_ids, repo.numeric_id_count); + var lo: usize = 0; + var hi: usize = entries.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + if (entries[mid].numeric_id < numeric_id) lo = mid + 1 else hi = mid; + } + var end = lo; + while (end < entries.len and entries[end].numeric_id == numeric_id) end += 1; + return entries[lo..end]; +} + +const KeyLookup = enum { found, missing, failed }; + +/// The Zova node key of `stable_id`. `failed` covers a NULL or empty id, an +/// index that cannot be built, and a node whose key is not positive. +fn repoNodeKeyForStableId(repo: *Repository, stable_id: ?Str, out_key: *i64) KeyLookup { + const wanted = nonEmpty(stable_id) orelse return .failed; + out_key.* = 0; + const entries = repoNumericIdsFind(repo, stableNumericId(wanted)) orelse return .failed; + for (entries) |*entry| { + if (c.strcmp(entry.stable_id, wanted) == 0) { + out_key.* = entry.node_key; + return if (entry.node_key > 0) .found else .failed; + } + } + return .missing; +} + +/// Reads the graph nodes of `keys` into `out`, one result per key. The +/// caller releases `out` whatever the result; releasing a zeroed or already +/// released result is a no-op in the SDK. +fn repoNativeNodesByKey(repo: *const Repository, keys: [*c]const i64, count: usize, out: *KeyedNodes) bool { + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (!repoGraphName(repo, &graph_name)) return false; + const status = c.zova_graph_nodes_get_many_keyed(&c.zova_graph_nodes_get_many_keyed_request{ + .db = repo.db, + .graph_name = &graph_name, + .node_keys = keys, + .key_count = count, + .out_results = out, + }); + if (status != c.ZOVA_OK or out.len != count) { + _ = c.fprintf(interop.stderr(), "zova keyed node read failed status=%d requested=%zu returned=%zu\n", @as(c_int, @bitCast(status)), count, out.len); + return false; + } + return true; +} + +fn repoDegreeMany(repo: *const Repository, keys: [*c]const i64, count: usize, direction: c_int, edge_type: CStr, out: [*c]u64) bool { + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (out == null or !repoGraphName(repo, &graph_name)) return false; + return c.zova_graph_degree_many_keyed(&c.zova_graph_degree_many_keyed_request{ + .db = repo.db, + .graph_name = &graph_name, + .node_keys = keys, + .node_count = count, + .direction = direction, + .edge_type = edge_type, + .out_degrees = out, + .out_degrees_capacity = count, + }) == c.ZOVA_OK; +} + +fn repoNow() c.struct_timespec { + var now = std.mem.zeroes(c.struct_timespec); + _ = c.cbm_clock_gettime(c.CLOCK_MONOTONIC, &now); + return now; +} + +fn repoElapsedMs(started: c.struct_timespec) f64 { + const finished = repoNow(); + return @as(f64, @floatFromInt(finished.tv_sec - started.tv_sec)) * 1000.0 + + @as(f64, @floatFromInt(finished.tv_nsec - started.tv_nsec)) / 1000000.0; +} + +// ── Repository open and close ─────────────────────────────────── + +/// Finds the one ready, healthy workspace of `stored_project` and records its +/// id, key and generation in `repo`, which keeps the name the caller used. +fn repoLoadWorkspace(repo: *Repository, project: Str, stored_project: CStr) bool { + if (c.cbm_zova_register_sql_functions(repo.db) != 0) return false; + const sql = + "SELECT r.workspace_id,p.workspace_key,g.generation FROM cbm_projects_v1 p " ++ + "JOIN cbm_workspace_registry r ON r.workspace_key=p.workspace_key " ++ + "JOIN cbm_database_generation_v1 g ON g.workspace_key=p.workspace_key " ++ + "WHERE p.project=?1 AND g.state='ready' AND g.generation=(SELECT MAX(g2.generation) " ++ + "FROM cbm_database_generation_v1 g2 WHERE g2.workspace_key=p.workspace_key " ++ + "AND g2.state='ready') AND NOT EXISTS (SELECT 1 FROM cbm_workspace_health_v1 h " ++ + "WHERE h.workspace_key=p.workspace_key AND h.state='rebuild_required')"; + var workspace: [*c]u8 = null; + defer c.free(workspace); + { + const statement = repoPrepare(repo.db, sql) orelse return false; + defer repoFinalize(statement); + if (!repoBindText(statement, 1, stored_project)) return false; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoStep(statement, &step) or step != c.ZOVA_STEP_ROW) return false; + workspace = repoColumnText(statement, 0); + repo.workspace_key = repoColumnI64(statement, 1); + repo.generation = repoColumnI64(statement, 2); + // A second row means the project name is not unique. + var second: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoStep(statement, &second) or second == c.ZOVA_STEP_ROW) return false; + } + if (workspace == null or workspace[0] == 0 or c.strlen(workspace) >= ID_MAX or + c.strlen(project) >= PROJECT_CAP) return false; + _ = interop.bprint(&repo.workspace_id, "{s}", .{span(workspace)}); + _ = interop.bprint(&repo.project, "{s}", .{span(project)}); + return true; +} + +pub export fn cbm_zova_repository_open(maybe_path: ?Str, maybe_project: ?Str) ?*Repository { + if (!with_zova) return null; + const path = nonEmpty(maybe_path) orelse return null; + const project = nonEmpty(maybe_project) orelse return null; + var stored_project = interop.heapStrdup(project); + if (stored_project == null) return null; + defer c.free(stored_project); + // `project` may be a catalog selector; the tables hold the project name. + if (cbm_zova_catalog_open(path)) |catalog| { + defer cbm_zova_catalog_close(catalog); + const selectors = [_]Str{project}; + var scope = std.mem.zeroes(Scope); + defer cbm_zova_catalog_scope_free(&scope); + if (cbm_zova_catalog_resolve(catalog, &selectors, 1, false, &scope) == OK and scope.count == 1) { + const resolved = interop.heapStrdup(scope.projects[0].project); + if (resolved == null) return null; + c.free(stored_project); + stored_project = resolved; + } + } + const repo = @as(?*Repository, interop.callocN(Repository, 1)) orelse return null; + repo.db = repoOpenReadOnly(path) orelse { + c.free(repo); + return null; + }; + if (!repoLoadWorkspace(repo, project, stored_project)) { + cbm_zova_repository_close(repo); + return null; + } + return repo; +} + +pub export fn cbm_zova_repository_close(maybe_repo: ?*Repository) void { + if (!with_zova) return; + const repo = maybe_repo orelse return; + if (repo.db) |db| _ = c.zova_database_close(db); + c.free(repo.numeric_ids); + repoStringArenaFree(repo.numeric_id_strings); + c.free(repo); +} + +// ── Catalog ───────────────────────────────────────────────────── + +fn catalogProjectFree(project: *CatalogProject) void { + c.free(project.selector); + c.free(project.project); + c.free(project.root_path); + c.free(project.indexed_at); + c.free(project.model_fingerprint); + c.free(project.health_reason); + project.* = std.mem.zeroes(CatalogProject); +} + +/// Deep copy; a NULL string copies as "". False (and `destination` cleared) +/// on OOM. +fn catalogProjectCopy(destination: *CatalogProject, source: *const CatalogProject) bool { + destination.* = std.mem.zeroes(CatalogProject); + destination.workspace_key = source.workspace_key; + destination.vector_dimensions = source.vector_dimensions; + destination.generation = source.generation; + destination.ready = source.ready; + destination.healthy = source.healthy; + destination.workspace_id = source.workspace_id; + inline for (.{ "selector", "project", "root_path", "indexed_at", "model_fingerprint", "health_reason" }) |field| { + const text: CStr = @field(source, field); + @field(destination, field) = interop.heapStrdup(if (text != null) text else ""); + if (@field(destination, field) == null) { + catalogProjectFree(destination); + return false; + } + } + return true; +} + +fn catalogSelectorUsed(earlier: []const CatalogProject, selector: CStr) bool { + for (earlier) |*project| { + if (project.selector != null and c.strcmp(project.selector, selector) == 0) return true; + } + return false; +} + +fn catalogSelectorDisambiguate(selector: CStr, root_path: CStr, workspace_key: i64) [*c]u8 { + const needed = c.strlen(selector) + 1 + 16 + 1 + 20 + 1; + const result = interop.mallocN(u8, needed); + if (result == null) return null; + _ = interop.bprint(result[0..needed], "{s}-{x:0>16}-{d}", .{ span(selector), fnv1a64(root_path), workspace_key }); + return result; +} + +/// Gives every project a unique selector: the shortest path suffix that no +/// earlier project uses, or a hashed name when the whole path is not enough. +fn catalogAssignSelectors(catalog: *Catalog) bool { + const projects = catalog.items(); + for (projects, 0..) |*project, i| { + var parent_levels: usize = 0; + var selector = c.cbm_project_selector_from_path(project.root_path, parent_levels); + if (selector == null) return false; + while (catalogSelectorUsed(projects[0..i], selector)) { + parent_levels += 1; + var next = c.cbm_project_selector_from_path(project.root_path, parent_levels); + if (next == null) { + c.free(selector); + return false; + } + if (c.strcmp(next, selector) == 0) { + c.free(next); + next = catalogSelectorDisambiguate(selector, project.root_path, project.workspace_key); + if (next == null) { + c.free(selector); + return false; + } + } + c.free(selector); + selector = next; + } + project.selector = selector; + } + return true; +} + +fn catalogProjectSelectorCompare(left: ?*const anyopaque, right: ?*const anyopaque) callconv(.c) c_int { + return c.strcmp(cast(CatalogProject, left).selector, cast(CatalogProject, right).selector); +} + +/// Reads one registry row into `project`. False when a column could not be +/// read; the caller frees the row. +fn catalogReadProject(statement: ?*Stmt, project: *CatalogProject) bool { + const workspace_id = repoColumnText(statement, 0); + defer c.free(workspace_id); + project.workspace_key = repoColumnI64(statement, 1); + project.root_path = repoColumnText(statement, 2); + project.project = repoColumnText(statement, 3); + project.indexed_at = repoColumnText(statement, 4); + project.model_fingerprint = repoColumnText(statement, 5); + project.vector_dimensions = repoColumnInt(statement, 6); + project.generation = repoColumnI64(statement, 7); + project.ready = repoColumnI64(statement, 8) != 0; + const health = repoColumnText(statement, 9); + defer c.free(health); + project.health_reason = repoColumnText(statement, 10); + project.healthy = health != null and c.strcmp(health, "rebuild_required") != 0; + if (workspace_id == null or workspace_id[0] == 0 or c.strlen(workspace_id) >= ID_MAX or + project.root_path == null or project.project == null or project.indexed_at == null or + project.model_fingerprint == null or health == null or project.health_reason == null) + return false; + _ = interop.bprint(&project.workspace_id, "{s}", .{span(workspace_id)}); + return true; +} + +/// Opens the database, starts the read transaction that pins the catalog's +/// view, and loads every registered workspace. The caller closes the catalog +/// on failure. +fn catalogLoad(catalog: *Catalog, path: Str) Error!void { + catalog.db = repoOpenReadOnly(path) orelse return error.Failed; + if (c.cbm_zova_register_sql_functions(catalog.db) != 0 or !repoExec(catalog.db, "BEGIN")) + return error.Failed; + catalog.transaction_open = true; + + const sql = + "SELECT r.workspace_id,r.workspace_key,r.canonical_root,COALESCE(p.project,'')," ++ + "COALESCE(p.indexed_at,''),COALESCE(s.model_fingerprint,'')," ++ + "COALESCE(s.vector_dimensions,0),r.active_generation," ++ + "CASE WHEN g.state='ready' THEN 1 ELSE 0 END," ++ + "COALESCE(h.state,'healthy'),COALESCE(h.reason,'') " ++ + "FROM cbm_workspace_registry r " ++ + "LEFT JOIN cbm_projects_v1 p ON p.workspace_key=r.workspace_key " ++ + "LEFT JOIN cbm_workspace_index_state_v1 s ON s.workspace_key=r.workspace_key " ++ + "LEFT JOIN cbm_database_generation_v1 g ON g.workspace_key=r.workspace_key " ++ + "AND g.generation=r.active_generation " ++ + "LEFT JOIN cbm_workspace_health_v1 h ON h.workspace_key=r.workspace_key " ++ + "ORDER BY r.workspace_key"; + { + const statement = repoPrepare(catalog.db, sql) orelse return error.Failed; + defer repoFinalize(statement); + var capacity: c_int = 8; + catalog.projects = interop.callocN(CatalogProject, @intCast(capacity)); + if (catalog.projects == null) return error.Failed; + while (try repoNextRow(statement)) { + if (catalog.count == capacity) { + if (capacity > @divTrunc(INT_MAX, 2)) return error.Failed; + const old_capacity: usize = @intCast(capacity); + capacity *= 2; + const grown = interop.reallocN(CatalogProject, catalog.projects, @intCast(capacity)); + if (grown == null) return error.Failed; + catalog.projects = grown; + @memset(grown[old_capacity..@intCast(capacity)], std.mem.zeroes(CatalogProject)); + } + const project = &many(catalog.projects, capacity)[@intCast(catalog.count)]; + if (!catalogReadProject(statement, project)) { + catalogProjectFree(project); + return error.Failed; + } + catalog.count += 1; + } + } + if (!catalogAssignSelectors(catalog)) return error.Failed; + if (catalog.count > 1) { + c.qsort(catalog.projects, @intCast(catalog.count), @sizeOf(CatalogProject), catalogProjectSelectorCompare); + } +} + +pub export fn cbm_zova_catalog_open(maybe_path: ?Str) ?*Catalog { + if (!with_zova) return null; + const path = nonEmpty(maybe_path) orelse return null; + const catalog = @as(?*Catalog, interop.callocN(Catalog, 1)) orelse return null; + catalogLoad(catalog, path) catch { + cbm_zova_catalog_close(catalog); + return null; + }; + return catalog; +} + +pub export fn cbm_zova_catalog_close(maybe_catalog: ?*Catalog) void { + if (!with_zova) return; + const catalog = maybe_catalog orelse return; + if (catalog.db != null and catalog.transaction_open) _ = repoExec(catalog.db, "ROLLBACK"); + for (catalog.items()) |*project| catalogProjectFree(project); + c.free(catalog.projects); + if (catalog.db) |db| _ = c.zova_database_close(db); + c.free(catalog); +} + +pub export fn cbm_zova_catalog_scope_free(maybe_scope: ?*Scope) void { + const scope = maybe_scope orelse return; + if (with_zova) { + for (many(scope.projects, scope.count)) |*project| catalogProjectFree(project); + for (many(scope.excluded_projects, scope.excluded_count)) |*project| catalogProjectFree(project); + c.free(scope.projects); + c.free(scope.excluded_projects); + } + scope.* = std.mem.zeroes(Scope); +} + +pub export fn cbm_zova_catalog_list(maybe_catalog: ?*Catalog, maybe_out: ?*Scope) c_int { + if (!with_zova) { + if (maybe_out) |out| out.* = std.mem.zeroes(Scope); + return ERR; + } + const catalog = maybe_catalog orelse return ERR; + const out = maybe_out orelse return ERR; + out.* = std.mem.zeroes(Scope); + const projects = catalog.items(); + if (projects.len == 0) return OK; + out.projects = interop.callocN(CatalogProject, projects.len); + if (out.projects == null) return ERR; + for (projects, many(out.projects, projects.len)) |*source, *copy| { + if (!catalogProjectCopy(copy, source)) { + cbm_zova_catalog_scope_free(out); + return ERR; + } + out.count += 1; + } + return OK; +} + +pub export fn cbm_zova_catalog_error(maybe_catalog: ?*const Catalog) Str { + if (!with_zova) return ""; + const catalog = maybe_catalog orelse return ""; + return if (catalog.error_text[0] != 0) @ptrCast(&catalog.error_text) else ""; +} + +/// Fills `out` (already zeroed) for cbm_zova_catalog_resolve; the caller +/// frees `out` unless this returns OK. +fn catalogResolveInto(catalog: *Catalog, selectors: ?[*]const Str, selector_count: c_int, wildcard: bool, out: *Scope) c_int { + const projects = catalog.items(); + const wanted: usize = @intCast(if (wildcard) catalog.count else selector_count); + if (wanted != 0) { + out.projects = interop.callocN(CatalogProject, wanted); + if (out.projects == null) return ERR; + } + const included = many(out.projects, wanted); + + if (wildcard) { + if (wanted != 0) { + out.excluded_projects = interop.callocN(CatalogProject, wanted); + if (out.excluded_projects == null) return ERR; + } + const excluded = many(out.excluded_projects, wanted); + for (projects) |*project| { + if (!project.ready or !project.healthy) { + if (!catalogProjectCopy(&excluded[@intCast(out.excluded_count)], project)) return ERR; + out.excluded_count += 1; + } else { + if (!catalogProjectCopy(&included[@intCast(out.count)], project)) return ERR; + out.count += 1; + } + } + return OK; + } + for (selectors.?[0..wanted]) |selector| { + const match: *const CatalogProject = for (projects) |*project| { + if (c.strcmp(project.selector, selector) == 0) break project; + } else { + _ = interop.bprint(&catalog.error_text, "project '{s}' was not found", .{span(selector)}); + return NOT_FOUND; + }; + if (!match.ready or !match.healthy) { + const reason: []const u8 = if (!match.ready) + "no ready generation" + else if (match.health_reason[0] != 0) + span(match.health_reason) + else + "workspace unhealthy"; + _ = interop.bprint(&catalog.error_text, "project '{s}' is unavailable: {s}", .{ span(selector), reason }); + return ERR; + } + if (!catalogProjectCopy(&included[@intCast(out.count)], match)) return ERR; + out.count += 1; + } + return OK; +} + +pub export fn cbm_zova_catalog_resolve(maybe_catalog: ?*Catalog, selectors: ?[*]const Str, selector_count: c_int, wildcard: bool, maybe_out: ?*Scope) c_int { + if (!with_zova) { + if (maybe_out) |out| out.* = std.mem.zeroes(Scope); + return ERR; + } + const catalog = maybe_catalog orelse return ERR; + const out = maybe_out orelse return ERR; + if (!wildcard and (selectors == null or selector_count <= 0)) return ERR; + catalog.error_text[0] = 0; + out.* = std.mem.zeroes(Scope); + const rc = catalogResolveInto(catalog, selectors, selector_count, wildcard, out); + if (rc != OK) cbm_zova_catalog_scope_free(out); + return rc; +} + +/// "WITH scope(workspace_key,selector) AS (VALUES (?3,?4),(?5,?6)…) " for the +/// projects of `scope`, malloc'd. `next_bind` receives the first parameter +/// index after the scope's. +fn catalogScopeCte(scope: *const Scope, next_bind: *c_int) ?[*:0]u8 { + if (scope.count <= 0) return null; + const capacity = 64 + @as(usize, @intCast(scope.count)) * 32; + const sql = (@as(?[*]u8, interop.mallocN(u8, capacity)) orelse return null)[0..capacity]; + var used = interop.bprint(sql, "WITH scope(workspace_key,selector) AS (VALUES ", .{}); + var bind: c_int = 3; + for (0..@intCast(scope.count)) |i| { + const separator: []const u8 = if (i != 0) "," else ""; + const written = interop.bprint(sql[used..], "{s}(?{d},?{d})", .{ separator, bind, bind + 1 }); + if (written >= capacity - used) { + c.free(sql.ptr); + return null; + } + used += written; + bind += 2; + } + if (used + 3 > capacity) { + c.free(sql.ptr); + return null; + } + @memcpy(sql[used..][0..3], ") \x00"); + next_bind.* = bind; + return @ptrCast(sql.ptr); +} + +/// Binds ?1 (query), ?2 (file pattern or NULL) and the scope's pairs. +fn catalogBindFtsScope(statement: ?*Stmt, scope: *const Scope, query: Str, file_pattern: ?Str) bool { + if (!repoBindText(statement, 1, query)) return false; + const pattern_bound = if (nonEmpty(file_pattern)) |pattern| + repoBindText(statement, 2, pattern) + else + repoBindNull(statement, 2); + if (!pattern_bound) return false; + var bind: c_int = 3; + for (many(scope.projects, scope.count)) |*project| { + if (!repoBindI64(statement, bind, project.workspace_key) or + !repoBindText(statement, bind + 1, project.selector)) return false; + bind += 2; + } + return true; +} + +/// malloc'd "" plus `extra` spare bytes; NULL on OOM. +fn catalogSql(cte: Str, tail: []const u8, extra: usize) ?[]u8 { + const capacity = c.strlen(cte) + tail.len + extra + 1; + const sql = (@as(?[*]u8, interop.mallocN(u8, capacity)) orelse return null)[0..capacity]; + _ = interop.bprint(sql, "{s}{s}", .{ span(cte), tail }); + return sql; +} + +/// The number of FTS matches in the scope, before paging. +fn catalogFtsTotal(catalog: *Catalog, scope: *const Scope, cte: Str, query: Str, file_pattern: ?Str) ?i64 { + const count_tail = + "SELECT count(*) FROM scope sc JOIN cbm_nodes_v1 n " ++ + "ON n.workspace_key=sc.workspace_key " ++ + "JOIN cbm_nodes_fts_v1 f ON f.rowid=n.zova_node_key " ++ + "JOIN cbm_files_v1 p ON p.file_key=n.file_key " ++ + "WHERE cbm_nodes_fts_v1 MATCH ?1 " ++ + "AND n.label NOT IN ('File','Folder','Module','Section','Variable','Project') " ++ + "AND (?2 IS NULL OR p.file_path GLOB ?2)"; + const count_sql = catalogSql(cte, count_tail, 0) orelse return null; + defer c.free(count_sql.ptr); + const statement = repoPrepare(catalog.db, @ptrCast(count_sql.ptr)) orelse return null; + defer repoFinalize(statement); + if (!catalogBindFtsScope(statement, scope, query, file_pattern)) return null; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoStep(statement, &step) or step != c.ZOVA_STEP_ROW) return null; + return repoColumnI64(statement, 0); +} + +/// The rows of one FTS page with, per row, its Zova node key and workspace. +const FtsRows = struct { + results: [*c]SearchResult = null, + keys: [*c]i64 = null, + workspace_keys: [*c]i64 = null, + count: c_int = 0, +}; + +/// Runs the paged FTS query and collects its rows. +fn catalogFtsReadRows(catalog: *Catalog, scope: *const Scope, cte: Str, next_bind: c_int, query: Str, file_pattern: ?Str, limit: c_int, offset: c_int, rows: *FtsRows) Error!void { + const data_tail = + "SELECT n.workspace_key,n.zova_node_key,sc.selector,n.label,n.name," ++ + "n.qualified_name,p.file_path,n.start_line,n.end_line,n.properties," ++ + "(bm25(cbm_nodes_fts_v1)-CASE WHEN n.label IN ('Function','Method') THEN 10.0 " ++ + "WHEN n.label='Route' THEN 8.0 WHEN n.label IN " ++ + "('Class','Interface','Type','Enum') THEN 5.0 ELSE 0.0 END) AS rank " ++ + "FROM scope sc JOIN cbm_nodes_v1 n ON n.workspace_key=sc.workspace_key " ++ + "JOIN cbm_nodes_fts_v1 f ON f.rowid=n.zova_node_key " ++ + "JOIN cbm_files_v1 p ON p.file_key=n.file_key " ++ + "WHERE cbm_nodes_fts_v1 MATCH ?1 " ++ + "AND n.label NOT IN ('File','Folder','Module','Section','Variable','Project') " ++ + "AND (?2 IS NULL OR p.file_path GLOB ?2) " ++ + "ORDER BY rank,sc.selector,n.qualified_name,n.zova_node_key "; + const statement = prepared: { + const data_sql = catalogSql(cte, data_tail, 79) orelse return error.Failed; + defer c.free(data_sql.ptr); + const head = c.strlen(data_sql.ptr); + _ = interop.bprint(data_sql[head..], "LIMIT ?{d} OFFSET ?{d}", .{ next_bind, next_bind + 1 }); + break :prepared repoPrepare(catalog.db, @ptrCast(data_sql.ptr)) orelse return error.Failed; + }; + defer repoFinalize(statement); + if (!catalogBindFtsScope(statement, scope, query, file_pattern) or + !repoBindI64(statement, next_bind, limit) or + !repoBindI64(statement, next_bind + 1, offset)) return error.Failed; + + var capacity: c_int = @max(@min(limit, 64), 1); + rows.results = interop.callocN(SearchResult, @intCast(capacity)); + rows.keys = interop.callocN(i64, @intCast(capacity)); + rows.workspace_keys = interop.callocN(i64, @intCast(capacity)); + if (rows.results == null or rows.keys == null or rows.workspace_keys == null) return error.Failed; + while (try repoNextRow(statement)) { + if (rows.count == capacity) { + const old_capacity: usize = @intCast(capacity); + if (capacity > @divTrunc(INT_MAX, 2)) return error.Failed; + capacity *= 2; + const grown_results = interop.reallocN(SearchResult, rows.results, @intCast(capacity)); + if (grown_results != null) rows.results = grown_results; + const grown_keys = interop.reallocN(i64, rows.keys, @intCast(capacity)); + if (grown_keys != null) rows.keys = grown_keys; + const grown_workspace_keys = interop.reallocN(i64, rows.workspace_keys, @intCast(capacity)); + if (grown_workspace_keys != null) rows.workspace_keys = grown_workspace_keys; + if (grown_results == null or grown_keys == null or grown_workspace_keys == null) return error.Failed; + @memset(grown_results[old_capacity..@intCast(capacity)], std.mem.zeroes(SearchResult)); + } + const index: usize = @intCast(rows.count); + rows.workspace_keys[index] = repoColumnI64(statement, 0); + rows.keys[index] = repoColumnI64(statement, 1); + const result = &many(rows.results, capacity)[index]; + const node = &result.node; + node.project = repoColumnText(statement, 2); + node.label = repoColumnText(statement, 3); + node.name = repoColumnText(statement, 4); + node.qualified_name = repoColumnText(statement, 5); + node.file_path = repoColumnText(statement, 6); + node.start_line = repoColumnInt(statement, 7); + node.end_line = repoColumnInt(statement, 8); + node.properties_json = repoColumnText(statement, 9); + result.rank = repoColumnDouble(statement, 10); + if (node.project == null or node.label == null or node.name == null or + node.qualified_name == null or node.file_path == null or node.properties_json == null) + { + c.cbm_node_free_fields(node); + return error.Failed; + } + rows.count += 1; + } +} + +/// Sets each row's numeric id and label from the graph node behind it, one +/// keyed read per project. +fn catalogFtsApplyNative(catalog: *Catalog, scope: *const Scope, rows: *FtsRows) Error!void { + const results = many(rows.results, rows.count); + const keys = many(rows.keys, rows.count); + const workspace_keys = many(rows.workspace_keys, rows.count); + for (many(scope.projects, scope.count)) |*project| { + var project_count: usize = 0; + for (workspace_keys) |key| { + if (key == project.workspace_key) project_count += 1; + } + if (project_count == 0) continue; + const project_keys = interop.mallocN(i64, project_count); + defer c.free(project_keys); + const positions = interop.mallocN(usize, project_count); + defer c.free(positions); + if (project_keys == null or positions == null) return error.Failed; + var written: usize = 0; + for (workspace_keys, keys, 0..) |workspace_key, key, i| { + if (workspace_key != project.workspace_key) continue; + project_keys[written] = key; + positions[written] = i; + written += 1; + } + var workspace = Repository{ .db = catalog.db, .workspace_key = project.workspace_key }; + workspace.workspace_id = project.workspace_id; + var native = std.mem.zeroes(KeyedNodes); + defer c.zova_graph_keyed_node_results_free(&native); + if (!repoNativeNodesByKey(&workspace, project_keys, project_count, &native)) return error.Failed; + for (many(native.items, project_count), positions[0..project_count]) |*item, position| { + if (item.found == 0 or item.node_id == null or item.kind == null) return error.Failed; + const node = &results[position].node; + node.id = stableNumericId(item.node_id); + freeConst(node.label); + node.label = repoDup(item.kind, item.kind_len); + if (node.label == null) return error.Failed; + } + } +} + +pub export fn cbm_zova_catalog_search_fts(maybe_catalog: ?*Catalog, maybe_scope: ?*const Scope, maybe_query: ?Str, file_pattern: ?Str, limit_arg: c_int, offset: c_int, maybe_out: ?*SearchOutput) c_int { + if (!with_zova) { + if (maybe_out) |out| out.* = std.mem.zeroes(SearchOutput); + return ERR; + } + const catalog = maybe_catalog orelse return ERR; + const scope = maybe_scope orelse return ERR; + const query = nonEmpty(maybe_query) orelse return ERR; + const out = maybe_out orelse return ERR; + if (scope.count <= 0 or offset < 0) return ERR; + out.* = std.mem.zeroes(SearchOutput); + const limit = if (limit_arg <= 0) 10 else limit_arg; + + var next_bind: c_int = 0; + const cte = catalogScopeCte(scope, &next_bind) orelse return ERR; + defer c.free(cte); + const total = catalogFtsTotal(catalog, scope, cte, query, file_pattern) orelse return ERR; + if (total < 0 or total > INT_MAX) return ERR; + + var rows = FtsRows{}; + defer { + c.free(rows.keys); + c.free(rows.workspace_keys); + } + read: { + catalogFtsReadRows(catalog, scope, cte, next_bind, query, file_pattern, limit, offset, &rows) catch break :read; + catalogFtsApplyNative(catalog, scope, &rows) catch break :read; + out.results = rows.results; + out.count = rows.count; + out.total = @intCast(total); + return OK; + } + var failed = SearchOutput{ .results = rows.results, .count = rows.count, .total = 0 }; + c.cbm_store_search_free(&failed); + return ERR; +} + +fn catalogSearchResultCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + const left = &cast(SearchResult, left_ptr).node; + const right = &cast(SearchResult, right_ptr).node; + inline for (.{ "name", "qualified_name", "project" }) |field| { + const a: CStr = @field(left, field); + const b: CStr = @field(right, field); + const compared = c.strcmp(if (a != null) a else "", if (b != null) b else ""); + if (compared != 0) return compared; + } + return @as(c_int, @intFromBool(left.id > right.id)) - @intFromBool(left.id < right.id); +} + +/// The matches of every project in a scope, before sorting and paging. +const Combined = struct { + results: [*c]SearchResult = null, + count: c_int = 0, + capacity: c_int = 0, + + fn free(combined: *Combined) void { + var owned = SearchOutput{ .results = combined.results, .count = combined.count, .total = 0 }; + c.cbm_store_search_free(&owned); + } +}; + +/// Runs the structured search in every project of the scope and moves the +/// matches into `combined`, each under its project's selector. +fn catalogSearchCollect(catalog: *Catalog, scope: *const Scope, params: *const SearchParams, combined: *Combined) Error!void { + for (many(scope.projects, scope.count)) |*project| { + var repository = Repository{ + .db = catalog.db, + .workspace_key = project.workspace_key, + .generation = project.generation, + }; + repository.workspace_id = project.workspace_id; + if (c.strlen(project.project) >= PROJECT_CAP) return error.Failed; + _ = interop.bprint(&repository.project, "{s}", .{span(project.project)}); + + var local_params = params.*; + local_params.project = project.project; + local_params.limit = INT_MAX; + local_params.offset = 0; + local_params.include_connected = false; + var local = std.mem.zeroes(SearchOutput); + defer c.cbm_store_search_free(&local); + if (cbm_zova_repository_search(&repository, repository.workspaceId(), &local_params, &local) != OK) + return error.Failed; + if (local.count > INT_MAX - combined.count) return error.Failed; + const needed = combined.count + local.count; + if (needed > combined.capacity) { + var grown_capacity: c_int = if (combined.capacity != 0) combined.capacity else 64; + while (grown_capacity < needed) { + if (grown_capacity > @divTrunc(INT_MAX, 2)) { + grown_capacity = needed; + break; + } + grown_capacity *= 2; + } + const grown = interop.reallocN(SearchResult, combined.results, @intCast(grown_capacity)); + if (grown == null) return error.Failed; + combined.results = grown; + @memset(grown[@intCast(combined.capacity)..@intCast(grown_capacity)], std.mem.zeroes(SearchResult)); + combined.capacity = grown_capacity; + } + for (many(local.results, local.count)) |*result| { + const selector = interop.heapStrdup(project.selector); + if (selector == null) return error.Failed; + freeConst(result.node.project); + result.node.project = selector; + combined.results[@intCast(combined.count)] = result.*; + combined.count += 1; + result.* = std.mem.zeroes(SearchResult); + } + } +} + +pub export fn cbm_zova_catalog_search(maybe_catalog: ?*Catalog, maybe_scope: ?*const Scope, maybe_params: ?*const SearchParams, maybe_out: ?*SearchOutput) c_int { + if (!with_zova) { + if (maybe_out) |out| out.* = std.mem.zeroes(SearchOutput); + return ERR; + } + const catalog = maybe_catalog orelse return ERR; + const scope = maybe_scope orelse return ERR; + const params = maybe_params orelse return ERR; + const out = maybe_out orelse return ERR; + if (scope.count <= 0 or params.offset < 0) return ERR; + out.* = std.mem.zeroes(SearchOutput); + var combined = Combined{}; + defer combined.free(); + catalogSearchCollect(catalog, scope, params, &combined) catch return ERR; + + if (combined.count > 1) { + c.qsort(combined.results, @intCast(combined.count), @sizeOf(SearchResult), catalogSearchResultCompare); + } + const limit = if (params.limit > 0) params.limit else 10; + const available = if (params.offset < combined.count) combined.count - params.offset else 0; + const page_count: usize = @intCast(@min(available, limit)); + var page: [*c]SearchResult = null; + if (page_count != 0) { + page = interop.callocN(SearchResult, page_count); + if (page == null) return ERR; + } + const sources = many(combined.results, combined.count); + for (many(page, page_count), 0..) |*slot, i| { + const source = &sources[@as(usize, @intCast(params.offset)) + i]; + slot.* = source.*; + source.* = std.mem.zeroes(SearchResult); + } + out.results = page; + out.count = @intCast(page_count); + out.total = combined.count; + return OK; +} + +pub export fn cbm_zova_catalog_search_semantic(maybe_catalog: ?*Catalog, maybe_path: ?Str, maybe_scope: ?*const Scope, keywords: [*c][*c]const u8, keyword_count: c_int, limit: c_int, offset: c_int, out: [*c][*c]c.cbm_vector_result_t, out_count: [*c]c_int, out_total: [*c]c_int) c_int { + if (!with_zova) { + if (out != null) out.* = null; + if (out_count != null) out_count.* = 0; + if (out_total != null) out_total.* = 0; + return ERR; + } + const catalog = maybe_catalog orelse return ERR; + const path = maybe_path orelse return ERR; + const scope = maybe_scope orelse return ERR; + if (scope.count <= 0 or keywords == null or keyword_count <= 0 or out == null or + out_count == null or out_total == null) return ERR; + out_total.* = 0; + const projects = many(scope.projects, scope.count); + const model: CStr = projects[0].model_fingerprint; + const dimensions = projects[0].vector_dimensions; + if (model == null or model[0] == 0 or dimensions <= 0) return c.CBM_ZOVA_CATALOG_INCOMPATIBLE_MODELS; + for (projects) |*project| { + if (project.model_fingerprint == null or c.strcmp(project.model_fingerprint, model) != 0 or + project.vector_dimensions != dimensions) + return c.CBM_ZOVA_CATALOG_INCOMPATIBLE_MODELS; + } + + const vector_counts = interop.callocN(c_int, projects.len); + defer c.free(vector_counts); + var total: i64 = 0; + { + const count_statement = repoPrepare(catalog.db, "SELECT node_vector_rows FROM cbm_generation_integrity_v2 " ++ + "WHERE workspace_key=?1 AND generation=?2"); + defer repoFinalize(count_statement); + if (count_statement == null or vector_counts == null) return ERR; + for (projects, 0..) |*project, i| { + if (c.zova_statement_reset(count_statement) != c.ZOVA_OK or + c.zova_statement_clear_bindings(count_statement) != c.ZOVA_OK or + !repoBindI64(count_statement, 1, project.workspace_key) or + !repoBindI64(count_statement, 2, project.generation)) return ERR; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoStep(count_statement, &step) or step != c.ZOVA_STEP_ROW) return ERR; + const count = repoColumnI64(count_statement, 0); + if (count < 0 or total > INT_MAX - count) return ERR; + vector_counts[i] = @intCast(count); + total += count; + } + } + out_total.* = @intCast(total); + + var rc: c_int = OK; + { + const workspaces = interop.callocN(c.cbm_zova_vector_workspace_t, projects.len); + if (workspaces == null) return ERR; + defer c.free(workspaces); + var workspace_count: usize = 0; + for (projects, 0..) |*project, i| { + if (vector_counts[i] == 0) continue; + workspaces[workspace_count] = .{ + .workspace_key = project.workspace_key, + .workspace_id = &project.workspace_id, + .project = project.project, + .selector = project.selector, + .model_fingerprint = project.model_fingerprint, + .vector_dimensions = project.vector_dimensions, + .generation = project.generation, + }; + workspace_count += 1; + } + if (workspace_count > 0) { + rc = c.cbm_store_zova_multi_vector_search(path, workspaces, @intCast(workspace_count), keywords, keyword_count, limit, offset, out, out_count); + } else { + out.* = null; + out_count.* = 0; + } + } + + // The search read through its own connection; the scope is stale when a + // project it names has moved to another generation or model since. + const current = if (rc == OK) cbm_zova_catalog_open(path) else null; + defer cbm_zova_catalog_close(current); + const selectors: ?[*]Str = interop.callocN(Str, projects.len); + defer c.free(@ptrCast(selectors)); + var verified = std.mem.zeroes(Scope); + defer cbm_zova_catalog_scope_free(&verified); + if (current == null or selectors == null) rc = ERR; + if (rc == OK) { + for (projects, selectors.?[0..projects.len]) |*project, *selector| selector.* = project.selector; + if (cbm_zova_catalog_resolve(current, selectors, scope.count, false, &verified) != OK) + rc = c.CBM_ZOVA_CATALOG_STALE_SCOPE; + } + if (rc == OK) { + for (projects, many(verified.projects, verified.count)) |*project, *now| { + if (now.workspace_key != project.workspace_key or now.generation != project.generation or + now.vector_dimensions != project.vector_dimensions or + c.strcmp(now.model_fingerprint, project.model_fingerprint) != 0) + rc = c.CBM_ZOVA_CATALOG_STALE_SCOPE; + } + } + if (rc != OK) { + c.cbm_store_free_vector_results(out.*, out_count.*); + out.* = null; + out_count.* = 0; + } + return rc; +} + +// ── Workspace reads: nodes ────────────────────────────────────── + +const KeyedNode = c.zova_graph_keyed_node_result; +const KeyedNeighbors = c.zova_graph_keyed_neighbor_results; +const EdgePayloads = c.zova_graph_edge_payload_results; +const ScanEdge = c.zova_graph_scan_edge; +const PayloadRecord = c.cbm_zova_edge_payload_record_t; + +/// (size_t)INT64_MAX: every neighbor. +const NEIGHBOR_LIMIT: usize = @truncate(@as(u64, std.math.maxInt(i64))); + +pub export fn cbm_zova_repository_workspace_id(maybe_repo: ?*const Repository) Str { + if (!with_zova) return ""; + const repo = maybe_repo orelse return ""; + return repo.workspaceId(); +} + +/// The repository when `workspace_id` is well formed and names its workspace. +fn repoWorkspaceRequired(maybe_repo: ?*Repository, workspace_id: ?Str) ?*Repository { + const repo = maybe_repo orelse return null; + if (c.cbm_zova_workspace_id_validate(workspace_id) != 0) return null; + return if (c.strcmp(repo.workspaceId(), workspace_id) == 0) repo else null; +} + +const node_by_key_sql = + "SELECT n.name,n.qualified_name,f.file_path,n.start_line,n.end_line,n.properties " ++ + "FROM cbm_nodes_v1 n JOIN cbm_files_v1 f ON f.file_key=n.file_key " ++ + "WHERE n.workspace_key=?1 AND n.zova_node_key=?2"; + +fn repoProjectDup(repo: *const Repository) [*c]u8 { + return repoDupZ(&repo.project); +} + +/// Fills `node` from its graph node and the current row of node_by_key_sql. +/// A string that could not be copied is left NULL. +fn repoFillNode(repo: *const Repository, statement: ?*Stmt, item: *const KeyedNode, node: *Node) void { + node.id = stableNumericId(item.node_id); + node.project = repoProjectDup(repo); + node.label = repoDup(item.kind, item.kind_len); + node.name = repoColumnText(statement, 0); + node.qualified_name = repoColumnText(statement, 1); + node.file_path = repoColumnText(statement, 2); + node.start_line = repoColumnInt(statement, 3); + node.end_line = repoColumnInt(statement, 4); + node.properties_json = repoColumnText(statement, 5); +} + +fn repoNodeComplete(node: *const Node) bool { + return node.project != null and node.label != null and node.name != null and + node.qualified_name != null and node.file_path != null and node.properties_json != null; +} + +/// Rebinds node_by_key_sql to `node_key` and steps to its row. +fn repoSeekNode(repo: *const Repository, statement: ?*Stmt, node_key: i64) bool { + if (c.zova_statement_reset(statement) != c.ZOVA_OK or + c.zova_statement_clear_bindings(statement) != c.ZOVA_OK or + !repoBindI64(statement, 1, repo.workspace_key) or + !repoBindI64(statement, 2, node_key)) return false; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + return repoStep(statement, &step) and step == c.ZOVA_STEP_ROW; +} + +fn hydrateNodeKey(repo: *Repository, node_key: i64, out: *Node) c_int { + if (node_key <= 0) return ERR; + const statement = repoPrepare(repo.db, node_by_key_sql) orelse return ERR; + defer repoFinalize(statement); + if (!repoBindI64(statement, 1, repo.workspace_key) or !repoBindI64(statement, 2, node_key)) return ERR; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoStep(statement, &step) or step != c.ZOVA_STEP_ROW) return NOT_FOUND; + var native = std.mem.zeroes(KeyedNodes); + defer c.zova_graph_keyed_node_results_free(&native); + if (!repoNativeNodesByKey(repo, &node_key, 1, &native)) return ERR; + const item = &many(native.items, 1)[0]; + if (item.found == 0 or item.node_id == null or item.kind == null) return ERR; + out.* = std.mem.zeroes(Node); + repoFillNode(repo, statement, item, out); + return OK; +} + +pub export fn cbm_zova_repository_find_node_by_qn(maybe_repo: ?*Repository, workspace_id: ?Str, maybe_qualified_name: ?Str, maybe_out: ?*Node) c_int { + if (!with_zova) return ERR; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const qualified_name = maybe_qualified_name orelse return ERR; + const out = maybe_out orelse return ERR; + var key: i64 = 0; + { + const statement = repoPrepare(repo.db, "SELECT zova_node_key FROM cbm_nodes_v1 " ++ + "WHERE workspace_key=?1 AND qualified_name=?2"); + defer repoFinalize(statement); + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (statement != null and repoBindI64(statement, 1, repo.workspace_key) and + repoBindText(statement, 2, qualified_name) and + repoStep(statement, &step) and step == c.ZOVA_STEP_ROW) + key = repoColumnI64(statement, 0); + } + return if (key > 0) hydrateNodeKey(repo, key, out) else NOT_FOUND; +} + +pub export fn cbm_zova_repository_find_node_by_stable_id(maybe_repo: ?*Repository, workspace_id: ?Str, maybe_stable_id: ?Str, maybe_out: ?*Node) c_int { + if (!with_zova) return ERR; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const stable_id = maybe_stable_id orelse return ERR; + const out = maybe_out orelse return ERR; + var key: i64 = 0; + return switch (repoNodeKeyForStableId(repo, stable_id, &key)) { + .found => hydrateNodeKey(repo, key, out), + .missing => NOT_FOUND, + .failed => ERR, + }; +} + +const StableNodeRequest = extern struct { + stable_id: CStr, + input_index: usize, + node_key: i64, +}; + +fn repoStableNodeRequestCompare(left: ?*const anyopaque, right: ?*const anyopaque) callconv(.c) c_int { + return c.strcmp(cast(StableNodeRequest, left).stable_id, cast(StableNodeRequest, right).stable_id); +} + +fn repoStableNodeRequestFind(requests: []StableNodeRequest, stable_id: CStr) ?*StableNodeRequest { + const key = StableNodeRequest{ .stable_id = stable_id, .input_index = 0, .node_key = 0 }; + return @ptrCast(@alignCast(c.bsearch(&key, requests.ptr, requests.len, @sizeOf(StableNodeRequest), repoStableNodeRequestCompare))); +} + +/// Fills `nodes[i]` for `stable_ids[i]`. `requests` and `keys` are scratch +/// arrays of the same length. NOT_FOUND when an id names no graph node. +fn repoNodesByStableIds(repo: *Repository, stable_ids: []const ?Str, requests: []StableNodeRequest, keys: []i64, nodes: []Node) c_int { + for (stable_ids, requests, 0..) |stable_id, *request, i| { + request.* = .{ .stable_id = nonEmpty(stable_id) orelse return ERR, .input_index = i, .node_key = 0 }; + } + if (requests.len > 1) c.qsort(requests.ptr, requests.len, @sizeOf(StableNodeRequest), repoStableNodeRequestCompare); + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (!repoGraphName(repo, &graph_name)) return ERR; + var cursor = std.mem.zeroes(ScanCursor); + while (true) { + var page = std.mem.zeroes(ScanResults); + defer c.zova_graph_scan_results_free(&page); + if (c.zova_graph_scan(&c.zova_graph_scan_request{ + .db = repo.db, + .graph_name = &graph_name, + .node_after = cursor, + .node_limit = GRAPH_SCAN_PAGE, + .edge_limit = 0, + .out_results = &page, + }) != c.ZOVA_OK) return ERR; + const page_nodes = many(page.nodes, page.nodes_len); + for (page_nodes) |*node| { + if (repoStableNodeRequestFind(requests, node.node_id)) |request| request.node_key = node.node_key; + } + if (page_nodes.len != 0) { + const last = &page_nodes[page_nodes.len - 1]; + cursor = .{ .created_order = last.created_order, .key = last.node_key }; + } + if (page.has_more_nodes == 0) break; + } + for (requests) |*request| { + if (request.node_key <= 0) return NOT_FOUND; + keys[request.input_index] = request.node_key; + } + var native = std.mem.zeroes(KeyedNodes); + defer c.zova_graph_keyed_node_results_free(&native); + if (!repoNativeNodesByKey(repo, keys.ptr, keys.len, &native)) return ERR; + const statement = repoPrepare(repo.db, node_by_key_sql) orelse return ERR; + defer repoFinalize(statement); + for (many(native.items, keys.len), keys, stable_ids, nodes) |*item, key, stable_id, *node| { + if (item.found == 0 or item.node_key != key or item.node_id == null or item.kind == null or + c.strcmp(item.node_id, stable_id) != 0) return ERR; + if (!repoSeekNode(repo, statement, key)) return ERR; + repoFillNode(repo, statement, item, node); + if (!repoNodeComplete(node)) return ERR; + } + return OK; +} + +pub export fn cbm_zova_repository_find_nodes_by_stable_ids(maybe_repo: ?*Repository, workspace_id: ?Str, stable_ids: ?[*]const ?Str, count: c_int, maybe_out: ?*[*c]Node) c_int { + if (!with_zova) return ERR; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const out = maybe_out orelse return ERR; + if (count < 0 or (count > 0 and stable_ids == null)) return ERR; + out.* = null; + if (count == 0) return OK; + const n: usize = @intCast(count); + const requests = interop.callocN(StableNodeRequest, n); + defer c.free(requests); + const keys = interop.callocN(i64, n); + defer c.free(keys); + const nodes = interop.callocN(Node, n); + const rc = if (requests == null or keys == null or nodes == null) + ERR + else + repoNodesByStableIds(repo, stable_ids.?[0..n], requests[0..n], keys[0..n], nodes[0..n]); + if (rc == OK) { + out.* = nodes; + } else if (rc == NOT_FOUND) { + c.free(nodes); + } else { + c.cbm_store_free_nodes(nodes, count); + } + return rc; +} + +fn repoNodesByKeys(repo: *Repository, node_keys: []const i64, nodes: []Node) bool { + for (node_keys) |key| { + if (key <= 0) return false; + } + var native = std.mem.zeroes(KeyedNodes); + defer c.zova_graph_keyed_node_results_free(&native); + if (!repoNativeNodesByKey(repo, node_keys.ptr, node_keys.len, &native)) return false; + const statement = repoPrepare(repo.db, node_by_key_sql) orelse return false; + defer repoFinalize(statement); + for (many(native.items, node_keys.len), node_keys, nodes) |*item, key, *node| { + if (item.found == 0 or item.node_key != key or item.node_id == null or item.kind == null or + !repoSeekNode(repo, statement, key)) return false; + repoFillNode(repo, statement, item, node); + if (!repoNodeComplete(node)) return false; + } + return true; +} + +pub export fn cbm_zova_repository_find_nodes_by_keys(maybe_repo: ?*Repository, workspace_id: ?Str, node_keys: ?[*]const i64, count: c_int, maybe_out: ?*[*c]Node) c_int { + if (!with_zova) return ERR; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const out = maybe_out orelse return ERR; + if (count < 0 or (count > 0 and node_keys == null)) return ERR; + out.* = null; + if (count == 0) return OK; + const n: usize = @intCast(count); + const nodes = interop.callocN(Node, n); + if (nodes == null) return ERR; + if (!repoNodesByKeys(repo, node_keys.?[0..n], nodes[0..n])) { + c.cbm_store_free_nodes(nodes, count); + return ERR; + } + out.* = nodes; + return OK; +} + +pub export fn cbm_zova_repository_stable_id_for_numeric_id(maybe_repo: ?*Repository, workspace_id: ?Str, numeric_id: i64, maybe_out: ?[*]u8, out_size: usize) c_int { + if (!with_zova) { + if (maybe_out) |out| { + if (out_size != 0) out[0] = 0; + } + return ERR; + } + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const out = (maybe_out orelse return ERR)[0..out_size]; + if (out.len == 0) return ERR; + out[0] = 0; + const entries = repoNumericIdsFind(repo, numeric_id) orelse return ERR; + if (entries.len == 0) return NOT_FOUND; + return if (interop.bprint(out, "{s}", .{span(entries[0].stable_id)}) < out.len) OK else ERR; +} + +pub export fn cbm_zova_repository_find_node_by_numeric_id(repo: ?*Repository, workspace_id: ?Str, numeric_id: i64, out: ?*Node) c_int { + if (!with_zova) return ERR; + var stable: [ID_MAX + 96]u8 = undefined; + const rc = cbm_zova_repository_stable_id_for_numeric_id(repo, workspace_id, numeric_id, &stable, stable.len); + return if (rc == OK) cbm_zova_repository_find_node_by_stable_id(repo, workspace_id, @ptrCast(&stable), out) else rc; +} + +/// Nodes read from SQL with the Zova key of each, before the graph read +/// that completes them. +const NodeRows = struct { + nodes: [*c]Node = null, + keys: [*c]i64 = null, + count: c_int = 0, +}; + +fn repoFileOverlapRead(repo: *Repository, file_path: Str, start_line: c_int, end_line: c_int, rows: *NodeRows) Error!void { + { + const sql = + "SELECT n.zova_node_key,n.name,n.qualified_name,f.file_path,n.start_line,n.end_line," ++ + "n.properties FROM cbm_nodes_v1 n JOIN cbm_files_v1 f ON f.file_key=n.file_key " ++ + "WHERE n.workspace_key=?1 AND f.file_path=?2 " ++ + "AND n.label NOT IN ('Module','Package','File','Folder') " ++ + "AND n.start_line<=?3 AND n.end_line>=?4 " ++ + "ORDER BY n.start_line,n.qualified_name,n.zova_node_key"; + const statement = repoPrepare(repo.db, sql) orelse return error.Failed; + defer repoFinalize(statement); + if (!repoBindI64(statement, 1, repo.workspace_key) or !repoBindText(statement, 2, file_path) or + !repoBindI64(statement, 3, end_line) or !repoBindI64(statement, 4, start_line)) return error.Failed; + var cap: usize = 8; + rows.nodes = interop.callocN(Node, cap); + rows.keys = interop.callocN(i64, cap); + if (rows.nodes == null or rows.keys == null) return error.Failed; + while (try repoNextRow(statement)) { + const index: usize = @intCast(rows.count); + if (index == cap) { + cap *= 2; + const grown_nodes = interop.reallocN(Node, rows.nodes, cap); + if (grown_nodes != null) rows.nodes = grown_nodes; + const grown_keys = interop.reallocN(i64, rows.keys, cap); + if (grown_keys != null) rows.keys = grown_keys; + if (grown_nodes == null or grown_keys == null) return error.Failed; + @memset(grown_nodes[index..cap], std.mem.zeroes(Node)); + } + rows.keys[index] = repoColumnI64(statement, 0); + const node = &many(rows.nodes, cap)[index]; + node.project = repoProjectDup(repo); + node.name = repoColumnText(statement, 1); + node.qualified_name = repoColumnText(statement, 2); + node.file_path = repoColumnText(statement, 3); + node.start_line = repoColumnInt(statement, 4); + node.end_line = repoColumnInt(statement, 5); + node.properties_json = repoColumnText(statement, 6); + rows.count += 1; + } + } + if (rows.count == 0) return; + const count: usize = @intCast(rows.count); + var native = std.mem.zeroes(KeyedNodes); + defer c.zova_graph_keyed_node_results_free(&native); + if (!repoNativeNodesByKey(repo, rows.keys, count, &native)) return error.Failed; + for (many(rows.nodes, count), many(native.items, count)) |*node, *item| { + if (item.found == 0 or item.node_id == null or item.kind == null) return error.Failed; + node.id = stableNumericId(item.node_id); + node.label = repoDup(item.kind, item.kind_len); + } +} + +pub export fn cbm_zova_repository_find_nodes_by_file_overlap(maybe_repo: ?*Repository, workspace_id: ?Str, maybe_file_path: ?Str, start_line: c_int, end_line: c_int, maybe_out: ?*[*c]Node, maybe_out_count: ?*c_int) c_int { + if (!with_zova) return ERR; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const file_path = maybe_file_path orelse return ERR; + const out = maybe_out orelse return ERR; + const out_count = maybe_out_count orelse return ERR; + out.* = null; + out_count.* = 0; + var rows = NodeRows{}; + defer c.free(rows.keys); + repoFileOverlapRead(repo, file_path, start_line, end_line, &rows) catch { + c.cbm_store_free_nodes(rows.nodes, rows.count); + return ERR; + }; + out.* = rows.nodes; + out_count.* = rows.count; + return OK; +} + +// ── Workspace reads: project and files ────────────────────────── + +pub export fn cbm_zova_repository_get_project(maybe_repo: ?*Repository, workspace_id: ?Str, maybe_out: ?*c.cbm_project_t) c_int { + if (!with_zova) return ERR; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const out = maybe_out orelse return ERR; + const statement = repoPrepare(repo.db, "SELECT project,indexed_at,root_path FROM cbm_projects_v1 " ++ + "WHERE workspace_key=?1") orelse return ERR; + defer repoFinalize(statement); + if (!repoBindI64(statement, 1, repo.workspace_key)) return ERR; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoStep(statement, &step) or step != c.ZOVA_STEP_ROW) return ERR; + out.* = std.mem.zeroes(c.cbm_project_t); + out.name = repoColumnText(statement, 0); + out.indexed_at = repoColumnText(statement, 1); + out.root_path = repoColumnText(statement, 2); + return OK; +} + +pub export fn cbm_zova_repository_index_status(maybe_repo: ?*Repository, workspace_id: ?Str, maybe_generation: ?*i64, maybe_indexed_at: ?*[*c]u8) c_int { + if (!with_zova) return ERR; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const generation = maybe_generation orelse return ERR; + const indexed_at = maybe_indexed_at orelse return ERR; + var project = std.mem.zeroes(c.cbm_project_t); + if (cbm_zova_repository_get_project(repo, workspace_id, &project) != OK) return ERR; + generation.* = repo.generation; + indexed_at.* = @constCast(project.indexed_at); + freeConst(project.name); + freeConst(project.root_path); + return OK; +} + +pub export fn cbm_zova_repository_counts(maybe_repo: ?*Repository, workspace_id: ?Str, maybe_nodes: ?*c_int, maybe_edges: ?*c_int) c_int { + if (!with_zova) return ERR; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const nodes = maybe_nodes orelse return ERR; + const edges = maybe_edges orelse return ERR; + const statement = repoPrepare(repo.db, "SELECT (SELECT count(*) FROM cbm_nodes_v1 WHERE workspace_key=?1)," ++ + "metadata_edges FROM cbm_generation_integrity_v2 " ++ + "WHERE workspace_key=?1 AND generation=?2") orelse return ERR; + defer repoFinalize(statement); + if (!repoBindI64(statement, 1, repo.workspace_key) or !repoBindI64(statement, 2, repo.generation)) return ERR; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoStep(statement, &step) or step != c.ZOVA_STEP_ROW) return ERR; + nodes.* = repoColumnInt(statement, 0); + edges.* = repoColumnInt(statement, 1); + return OK; +} + +/// Whether a Project node carries a coverage stamp. Generations indexed +/// before coverage stamps existed carry no marker. +fn repoCoverageRecorded(repo: *Repository) Error!bool { + const statement = repoPrepare(repo.db, "SELECT properties FROM cbm_nodes_v1 WHERE workspace_key=?1 " ++ + "AND label='Project'") orelse return error.Failed; + defer repoFinalize(statement); + if (!repoBindI64(statement, 1, repo.workspace_key)) return error.Failed; + var recorded = false; + while (try repoNextRow(statement)) { + const props = repoColumnText(statement, 0); + defer c.free(props); + if (props != null and c.strstr(props, "\"coverage_version\":") != null) recorded = true; + } + return recorded; +} + +fn repoRootPath(repo: *Repository) Error![*c]u8 { + const statement = repoPrepare(repo.db, "SELECT root_path FROM cbm_projects_v1 WHERE workspace_key=?1") orelse return error.Failed; + defer repoFinalize(statement); + if (!repoBindI64(statement, 1, repo.workspace_key)) return error.Failed; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoStep(statement, &step) or step != c.ZOVA_STEP_ROW) return error.Failed; + const root_path = repoColumnText(statement, 0); + return if (root_path != null) root_path else error.Failed; +} + +/// Reads the coverage rows into `rows`/`n`, which the caller owns either way. +fn repoFileCoverageRows(repo: *Repository, exact_path: ?Str, rows: *[*c]FileCoverage, n: *c_int) Error!void { + const statement = repoPrepare(repo.db, "SELECT f.file_path,n.properties,n.zova_node_key IS NOT NULL," ++ + "h.file_key IS NOT NULL,COALESCE(h.mtime_ns,0),COALESCE(h.size_bytes,0) " ++ + "FROM cbm_files_v1 f " ++ + "LEFT JOIN cbm_nodes_v1 n ON n.workspace_key=f.workspace_key " ++ + "AND n.file_key=f.file_key AND n.label='File' " ++ + "LEFT JOIN cbm_file_hashes_v1 h ON h.file_key=f.file_key " ++ + "WHERE f.workspace_key=?1 AND (?2 IS NULL OR f.file_path=?2) " ++ + "AND (n.zova_node_key IS NOT NULL OR h.file_key IS NOT NULL) " ++ + "ORDER BY f.file_path") orelse return error.Failed; + defer repoFinalize(statement); + if (!repoBindI64(statement, 1, repo.workspace_key)) return error.Failed; + if (exact_path) |path| { + if (!repoBindText(statement, 2, path)) return error.Failed; + } + var cap: c_int = 0; + while (try repoNextRow(statement)) { + if (n.* == cap) { + if (cap > @divTrunc(INT_MAX, 2)) return error.Failed; + const next: c_int = if (cap != 0) cap * 2 else 64; + const grown = interop.reallocN(FileCoverage, rows.*, @intCast(next)); + if (grown == null) return error.Failed; + rows.* = grown; + cap = next; + } + const row = &many(rows.*, cap)[@intCast(n.*)]; + row.* = std.mem.zeroes(FileCoverage); + row.file_path = repoColumnText(statement, 0); + const has_node = repoColumnI64(statement, 2) != 0; + row.properties = if (has_node) repoColumnText(statement, 1) else null; + row.has_hash = repoColumnI64(statement, 3) != 0; + row.mtime_ns = repoColumnI64(statement, 4); + row.size_bytes = repoColumnI64(statement, 5); + n.* += 1; + if (row.file_path == null or (has_node and row.properties == null)) return error.Failed; + } +} + +fn repoFileCoverageRead(repo: *Repository, exact_path: ?Str, out_root_path: ?*[*c]u8, out_recorded: ?*bool, rows: *[*c]FileCoverage, n: *c_int) Error!void { + if (out_recorded) |recorded| { + recorded.* = false; + recorded.* = try repoCoverageRecorded(repo); + } + if (out_root_path) |root_path| root_path.* = try repoRootPath(repo); + try repoFileCoverageRows(repo, exact_path, rows, n); +} + +pub export fn cbm_zova_repository_file_coverage(maybe_repo: ?*Repository, workspace_id: ?Str, exact_path: ?Str, maybe_out: ?*[*c]FileCoverage, maybe_count: ?*c_int, out_root_path: ?*[*c]u8, out_generation: ?*i64, out_recorded: ?*bool) c_int { + if (!with_zova) { + if (out_recorded) |recorded| recorded.* = false; + if (maybe_out) |out| out.* = null; + if (maybe_count) |count| count.* = 0; + if (out_root_path) |root_path| root_path.* = null; + return ERR; + } + const out = maybe_out orelse return ERR; + const count = maybe_count orelse return ERR; + out.* = null; + count.* = 0; + if (out_root_path) |root_path| root_path.* = null; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + if (out_generation) |generation| generation.* = repo.generation; + var rows: [*c]FileCoverage = null; + var n: c_int = 0; + repoFileCoverageRead(repo, exact_path, out_root_path, out_recorded, &rows, &n) catch { + cbm_zova_file_coverage_free(rows, n); + if (out_root_path) |root_path| { + c.free(root_path.*); + root_path.* = null; + } + return ERR; + }; + out.* = rows; + count.* = n; + return OK; +} + +pub export fn cbm_zova_file_coverage_free(rows: [*c]FileCoverage, count: c_int) void { + if (!with_zova) return; + if (rows == null) return; + for (many(rows, count)) |*row| { + c.free(row.file_path); + c.free(row.properties); + } + c.free(rows); +} + +pub export fn cbm_zova_repository_project_summary(maybe_repo: ?*Repository, workspace_id: ?Str, maybe_out: ?*c.cbm_project_summary_export_t) c_int { + if (!with_zova) return ERR; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const out = maybe_out orelse return ERR; + out.* = std.mem.zeroes(c.cbm_project_summary_export_t); + const statement = repoPrepare(repo.db, "SELECT summary,source_hash,created_at,updated_at " ++ + "FROM cbm_project_summaries_v2 WHERE workspace_key=?1") orelse return ERR; + defer repoFinalize(statement); + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoBindI64(statement, 1, repo.workspace_key) or !repoStep(statement, &step)) return ERR; + if (step == c.ZOVA_STEP_DONE) return NOT_FOUND; + out.summary = repoColumnText(statement, 0); + out.source_hash = repoColumnText(statement, 1); + out.created_at = repoColumnText(statement, 2); + out.updated_at = repoColumnText(statement, 3); + return OK; +} + +// ── Workspace reads: edges ────────────────────────────────────── + +/// Where repoAppendPayloadEdge puts the records of one graph edge. +const PayloadEdgeContext = struct { + repo: *const Repository, + source: CStr, + target: CStr, + edge_type: CStr, + edge_type_len: usize, + edges: *[*c]Edge, + count: *c_int, + capacity: *c_int, +}; + +fn repoAppendPayloadEdge(maybe_record: [*c]const PayloadRecord, context_ptr: ?*anyopaque) callconv(.c) c_int { + if (maybe_record == null) return -1; + const record: *const PayloadRecord = @ptrCast(maybe_record); + const context: *PayloadEdgeContext = @ptrCast(@alignCast(context_ptr orelse return -1)); + if (context.count.* == context.capacity.*) { + if (context.capacity.* > @divTrunc(INT_MAX, 2)) return -1; + const old_capacity: usize = @intCast(context.capacity.*); + const grown_capacity = context.capacity.* * 2; + const grown = interop.reallocN(Edge, context.edges.*, @intCast(grown_capacity)); + if (grown == null) return -1; + @memset(grown[old_capacity..@intCast(grown_capacity)], std.mem.zeroes(Edge)); + context.edges.* = grown; + context.capacity.* = grown_capacity; + } + var local_name: [ID_MAX + 80]u8 = undefined; + const local = many(record.local_name.data, record.local_name.len); + if (record.local_name.len >= local_name.len) return -1; + @memcpy(local_name[0..local.len], local); + local_name[record.local_name.len] = 0; + var edge_id: [ID_MAX + 80]u8 = undefined; + if (!repoEdgeId(context.repo.workspaceId(), context.source, context.edge_type, context.target, &local_name, &edge_id)) return -1; + const project = repoProjectDup(context.repo); + const edge_type = repoDup(context.edge_type, context.edge_type_len); + const properties = repoDup(record.properties.data, record.properties.len); + if (project == null or edge_type == null or properties == null) { + c.free(project); + c.free(edge_type); + c.free(properties); + return -1; + } + context.edges.*[@intCast(context.count.*)] = .{ + .id = stableNumericId(&edge_id), + .project = project, + .source_id = stableNumericId(context.source), + .target_id = stableNumericId(context.target), + .type = edge_type, + .properties_json = properties, + }; + context.count.* += 1; + return 0; +} + +pub export fn cbm_zova_repository_call_neighbor_keys(maybe_repo: ?*Repository, workspace_id: ?Str, maybe_stable_id: ?Str, inbound: bool, maybe_out: ?*[*c]i64, maybe_out_count: ?*c_int) c_int { + if (!with_zova) return ERR; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const stable_id = maybe_stable_id orelse return ERR; + const out = maybe_out orelse return ERR; + const out_count = maybe_out_count orelse return ERR; + out.* = null; + out_count.* = 0; + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (!repoGraphName(repo, &graph_name)) return ERR; + var neighbors = std.mem.zeroes(KeyedNeighbors); + defer c.zova_graph_keyed_neighbor_results_free(&neighbors); + if (c.zova_graph_neighbors_keyed(&c.zova_graph_neighbors_keyed_request{ + .db = repo.db, + .graph_name = &graph_name, + .node_id = stable_id, + .direction = if (inbound) c.ZOVA_GRAPH_NEIGHBOR_INCOMING else c.ZOVA_GRAPH_NEIGHBOR_OUTGOING, + .edge_type = null, + .limit = NEIGHBOR_LIMIT, + .out_results = &neighbors, + }) != c.ZOVA_OK) return ERR; + var keys: [*c]i64 = null; + if (neighbors.len != 0) { + keys = interop.callocN(i64, neighbors.len); + if (keys == null) return ERR; + } + var count: usize = 0; + for (many(neighbors.items, neighbors.len)) |*neighbor| { + const edge_type = many(neighbor.edge_type, neighbor.edge_type_len); + if (std.mem.eql(u8, edge_type, "CALLS") or std.mem.eql(u8, edge_type, "HTTP_CALLS") or + std.mem.eql(u8, edge_type, "ASYNC_CALLS")) + { + keys[count] = neighbor.neighbor_node_key; + count += 1; + } + } + out.* = keys; + out_count.* = @intCast(count); + return OK; +} + +/// Appends the edges between `stable_id` and its neighbors in one direction. +/// `skip_self_loops` drops the loops the outgoing pass already produced. +fn repoAppendNeighborEdges(repo: *Repository, graph_name: Str, stable_id: Str, direction: c_int, skip_self_loops: bool, edges: *[*c]Edge, count: *c_int, capacity: *c_int) bool { + var neighbors = std.mem.zeroes(KeyedNeighbors); + defer c.zova_graph_keyed_neighbor_results_free(&neighbors); + if (c.zova_graph_neighbors_keyed(&c.zova_graph_neighbors_keyed_request{ + .db = repo.db, + .graph_name = graph_name, + .node_id = stable_id, + .direction = direction, + .edge_type = null, + .limit = NEIGHBOR_LIMIT, + .out_results = &neighbors, + }) != c.ZOVA_OK) return false; + const items = many(neighbors.items, neighbors.len); + var payloads = std.mem.zeroes(EdgePayloads); + defer c.zova_graph_edge_payload_results_free(&payloads); + if (items.len != 0) { + const edge_keys = interop.callocN(i64, items.len); + if (edge_keys == null) return false; + defer c.free(edge_keys); + for (items, edge_keys[0..items.len]) |*item, *key| key.* = item.edge_key; + if (c.zova_graph_edge_payload_get_many(&c.zova_graph_edge_payload_get_many_request{ + .db = repo.db, + .graph_name = graph_name, + .edge_keys = edge_keys, + .key_count = items.len, + .out_results = &payloads, + }) != c.ZOVA_OK) return false; + } + if (payloads.len != items.len) return false; + const outgoing = direction == c.ZOVA_GRAPH_NEIGHBOR_OUTGOING; + for (items, many(payloads.items, items.len)) |*neighbor, *payload| { + if (skip_self_loops and c.strcmp(neighbor.node_id, stable_id) == 0) continue; + if (payload.found == 0 or payload.edge_key != neighbor.edge_key) return false; + var context = PayloadEdgeContext{ + .repo = repo, + .source = if (outgoing) stable_id else neighbor.node_id, + .target = if (outgoing) neighbor.node_id else stable_id, + .edge_type = neighbor.edge_type, + .edge_type_len = neighbor.edge_type_len, + .edges = edges, + .count = count, + .capacity = capacity, + }; + if (c.cbm_zova_edge_payload_visit(payload.payload, payload.payload_len, repoAppendPayloadEdge, &context, null) != 0) return false; + } + return true; +} + +pub export fn cbm_zova_repository_find_edges(maybe_repo: ?*Repository, workspace_id: ?Str, maybe_stable_id: ?Str, direction: ?Str, maybe_out: ?*[*c]Edge, maybe_out_count: ?*c_int) c_int { + if (!with_zova) return ERR; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const stable_id = maybe_stable_id orelse return ERR; + const out = maybe_out orelse return ERR; + const out_count = maybe_out_count orelse return ERR; + out.* = null; + out_count.* = 0; + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (!repoGraphName(repo, &graph_name)) return ERR; + const both = direction == null or c.strcmp(direction, "any") == 0; + const directions: []const c_int = if (both) + &.{ c.ZOVA_GRAPH_NEIGHBOR_OUTGOING, c.ZOVA_GRAPH_NEIGHBOR_INCOMING } + else if (c.strcmp(direction, "inbound") == 0) + &.{c.ZOVA_GRAPH_NEIGHBOR_INCOMING} + else + &.{c.ZOVA_GRAPH_NEIGHBOR_OUTGOING}; + var cap: c_int = 8; + var count: c_int = 0; + var edges: [*c]Edge = interop.callocN(Edge, @intCast(cap)); + if (edges == null) return ERR; + for (directions, 0..) |one, d| { + if (!repoAppendNeighborEdges(repo, @ptrCast(&graph_name), stable_id, one, both and d == 1, &edges, &count, &cap)) { + c.cbm_store_free_edges(edges, count); + return ERR; + } + } + out.* = edges; + out_count.* = count; + return OK; +} + +const EdgeRecordVisitor = *const fn (native: *const ScanEdge, record: *const PayloadRecord, context: ?*anyopaque) c_int; + +const ScanRecordContext = struct { + native: *const ScanEdge, + visitor: EdgeRecordVisitor, + context: ?*anyopaque, +}; + +fn repoScanPayloadRecord(record: [*c]const PayloadRecord, context_ptr: ?*anyopaque) callconv(.c) c_int { + const context: *const ScanRecordContext = @ptrCast(@alignCast(context_ptr)); + return context.visitor(context.native, @ptrCast(record), context.context); +} + +fn repoEdgeTypeMatches(edge: *const ScanEdge, edge_type: ?Str) bool { + const wanted = edge_type orelse return true; + return edge.edge_type != null and std.mem.eql(u8, many(edge.edge_type, edge.edge_type_len), span(wanted)); +} + +/// Visit every cbm edge record of the workspace graph in scan order, or only +/// those of edge_type. Payloads are read only for matching graph edges. +fn repoScanEdges(repo: *Repository, edge_type: ?Str, visitor: EdgeRecordVisitor, context: ?*anyopaque) c_int { + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (!repoGraphName(repo, &graph_name)) return ERR; + var cursor = std.mem.zeroes(ScanCursor); + var edge_keys: [EDGE_SCAN_PAGE]i64 = undefined; + var matched: [EDGE_SCAN_PAGE]*const ScanEdge = undefined; + while (true) { + var page = std.mem.zeroes(ScanResults); + defer c.zova_graph_scan_results_free(&page); + var payloads = std.mem.zeroes(EdgePayloads); + defer c.zova_graph_edge_payload_results_free(&payloads); + if (c.zova_graph_scan(&c.zova_graph_scan_request{ + .db = repo.db, + .graph_name = &graph_name, + .edge_after = cursor, + .node_limit = 0, + .edge_limit = EDGE_SCAN_PAGE, + .out_results = &page, + }) != c.ZOVA_OK or page.edges_len > EDGE_SCAN_PAGE) return ERR; + const page_edges = many(page.edges, page.edges_len); + var match_count: usize = 0; + for (page_edges) |*edge| { + if (!repoEdgeTypeMatches(edge, edge_type)) continue; + matched[match_count] = edge; + edge_keys[match_count] = edge.edge_key; + match_count += 1; + } + if (match_count != 0 and (c.zova_graph_edge_payload_get_many(&c.zova_graph_edge_payload_get_many_request{ + .db = repo.db, + .graph_name = &graph_name, + .edge_keys = &edge_keys, + .key_count = match_count, + .out_results = &payloads, + }) != c.ZOVA_OK or payloads.len != match_count)) return ERR; + for (matched[0..match_count], many(payloads.items, match_count)) |native, *payload| { + var edge = ScanRecordContext{ .native = native, .visitor = visitor, .context = context }; + if (payload.found == 0 or payload.edge_key != native.edge_key or + c.cbm_zova_edge_payload_visit(payload.payload, payload.payload_len, repoScanPayloadRecord, &edge, null) != 0) + return ERR; + } + if (page_edges.len != 0) { + const last = &page_edges[page_edges.len - 1]; + cursor = .{ .created_order = last.created_order, .key = last.edge_key }; + } + if (page.has_more_edges == 0) return OK; + } +} + +const EdgeVisitor = @typeInfo(c.cbm_zova_repository_edge_visitor_t).optional.child; + +const VisitEdgeContext = struct { + visitor: EdgeVisitor, + context: ?*anyopaque, +}; + +fn repoVisitEdgeRecord(native: *const ScanEdge, record: *const PayloadRecord, context_ptr: ?*anyopaque) c_int { + const context: *const VisitEdgeContext = @ptrCast(@alignCast(context_ptr)); + return context.visitor(native.source_node_key, native.target_node_key, native.edge_type, native.edge_type_len, record.properties.data, record.properties.len, context.context); +} + +pub export fn cbm_zova_repository_visit_edges(maybe_repo: ?*Repository, workspace_id: ?Str, edge_type: ?Str, visitor: c.cbm_zova_repository_edge_visitor_t, context: ?*anyopaque) c_int { + if (!with_zova) return ERR; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + var visit = VisitEdgeContext{ .visitor = visitor orelse return ERR, .context = context }; + return repoScanEdges(repo, edge_type, repoVisitEdgeRecord, &visit); +} + +const TypedEdge = extern struct { + source_key: i64, + target_key: i64, + local_name: [*c]u8, + properties: [*c]u8, +}; + +const TypedEdges = struct { + items: [*c]TypedEdge = null, + count: c_int = 0, + capacity: c_int = 0, +}; + +fn repoCollectTypedEdge(native: *const ScanEdge, record: *const PayloadRecord, context_ptr: ?*anyopaque) c_int { + const edges: *TypedEdges = @ptrCast(@alignCast(context_ptr)); + if (edges.count == edges.capacity) { + const capacity: c_int = if (edges.capacity != 0) edges.capacity * 2 else 64; + const grown = interop.reallocN(TypedEdge, edges.items, @intCast(capacity)); + if (grown == null) return -1; + edges.items = grown; + edges.capacity = capacity; + } + const local_name = repoDup(record.local_name.data, record.local_name.len); + const properties = repoDup(record.properties.data, record.properties.len); + if (local_name == null or properties == null) { + c.free(local_name); + c.free(properties); + return -1; + } + edges.items[@intCast(edges.count)] = .{ + .source_key = native.source_node_key, + .target_key = native.target_node_key, + .local_name = local_name, + .properties = properties, + }; + edges.count += 1; + return 0; +} + +fn repoInt64Compare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + const left = cast(i64, left_ptr).*; + const right = cast(i64, right_ptr).*; + return @as(c_int, @intFromBool(left > right)) - @intFromBool(left < right); +} + +/// The graph node read for `key` (keys sorted, natives parallel to them); +/// null unless it was found and has an id. +fn repoKeyedNodeFor(keys: []const i64, natives: []const KeyedNode, key: i64) ?*const KeyedNode { + const hit = c.bsearch(&key, keys.ptr, keys.len, @sizeOf(i64), repoInt64Compare) orelse return null; + const item = &natives[(@intFromPtr(hit) - @intFromPtr(keys.ptr)) / @sizeOf(i64)]; + return if (item.node_id != null and item.found != 0) item else null; +} + +/// Builds `edges[i]` from `items[i]`, taking over its properties. `key_buffer` +/// is scratch for two keys per item. +fn repoResolveTypedEdges(repo: *Repository, edge_type: Str, items: []TypedEdge, key_buffer: []i64, edges: []Edge) bool { + for (items, 0..) |*item, i| { + key_buffer[i * 2] = item.source_key; + key_buffer[i * 2 + 1] = item.target_key; + } + c.qsort(key_buffer.ptr, key_buffer.len, @sizeOf(i64), repoInt64Compare); + var unique: usize = 0; + for (key_buffer) |key| { + if (unique == 0 or key_buffer[unique - 1] != key) { + key_buffer[unique] = key; + unique += 1; + } + } + const keys = key_buffer[0..unique]; + var native = std.mem.zeroes(KeyedNodes); + defer c.zova_graph_keyed_node_results_free(&native); + if (!repoNativeNodesByKey(repo, keys.ptr, keys.len, &native)) return false; + const natives = many(native.items, keys.len); + for (items, edges) |*item, *edge| { + const source = repoKeyedNodeFor(keys, natives, item.source_key) orelse return false; + const target = repoKeyedNodeFor(keys, natives, item.target_key) orelse return false; + var edge_id: [ID_MAX + 80]u8 = undefined; + if (!repoEdgeId(repo.workspaceId(), source.node_id, edge_type, target.node_id, item.local_name, &edge_id)) return false; + edge.* = .{ + .id = stableNumericId(&edge_id), + .project = repoProjectDup(repo), + .source_id = stableNumericId(source.node_id), + .target_id = stableNumericId(target.node_id), + .type = repoDup(edge_type, c.strlen(edge_type)), + .properties_json = item.properties, + }; + item.properties = null; + if (edge.project == null or edge.type == null) return false; + } + return true; +} + +/// Every cbm edge of one type, in scan order, with the ids the other reads +/// use: endpoints and edge ids derive from stable ids, as in find_edges. +pub export fn cbm_zova_repository_find_edges_by_type(maybe_repo: ?*Repository, workspace_id: ?Str, maybe_edge_type: ?Str, maybe_out: ?*[*c]Edge, maybe_out_count: ?*c_int) c_int { + if (!with_zova) return ERR; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const edge_type = maybe_edge_type orelse return ERR; + const out = maybe_out orelse return ERR; + const out_count = maybe_out_count orelse return ERR; + out.* = null; + out_count.* = 0; + var found = TypedEdges{}; + defer { + for (many(found.items, found.count)) |*item| { + c.free(item.local_name); + c.free(item.properties); + } + c.free(found.items); + } + if (repoScanEdges(repo, edge_type, repoCollectTypedEdge, &found) != OK) return ERR; + const items = many(found.items, found.count); + if (items.len == 0) return OK; + const keys = interop.mallocN(i64, items.len * 2); + defer c.free(keys); + const edges = interop.callocN(Edge, items.len); + if (keys == null or edges == null or + !repoResolveTypedEdges(repo, edge_type, items, keys[0 .. items.len * 2], edges[0..items.len])) + { + c.cbm_store_free_edges(edges, found.count); + return ERR; + } + out.* = edges; + out_count.* = found.count; + return OK; +} + +// ── Workspace reads: graph identities ─────────────────────────── + +const IdentityEdges = struct { + items: [*c]IdentityEdge = null, + count: c_int = 0, + capacity: c_int = 0, + max_edges: u64, + over_limit: bool = false, +}; + +fn repoCollectIdentityEdge(native: *const ScanEdge, record: *const PayloadRecord, context_ptr: ?*anyopaque) c_int { + const edges: *IdentityEdges = @ptrCast(@alignCast(context_ptr)); + if (@as(u64, @intCast(edges.count)) >= edges.max_edges) { + edges.over_limit = true; + return -1; + } + if (edges.count == edges.capacity) { + const capacity: c_int = if (edges.capacity != 0) edges.capacity * 2 else 64; + const grown = interop.reallocN(IdentityEdge, edges.items, @intCast(capacity)); + if (grown == null) return -1; + edges.items = grown; + edges.capacity = capacity; + } + const edge_type = repoDup(native.edge_type, native.edge_type_len); + const local_name = repoDup(record.local_name.data, record.local_name.len); + if (edge_type == null or local_name == null) { + c.free(edge_type); + c.free(local_name); + return -1; + } + edges.items[@intCast(edges.count)] = .{ + .source_key = native.source_node_key, + .target_key = native.target_node_key, + .type = edge_type, + .local_name = local_name, + }; + edges.count += 1; + return 0; +} + +/// Reads the identity of every node of the workspace, at most `max_nodes`. +/// The caller owns `nodes`/`count` whatever the result. +fn repoIdentityNodes(repo: *Repository, max_nodes: u64, nodes: *[*c]IdentityNode, count: *c_int) c_int { + const statement = repoPrepare(repo.db, "SELECT n.zova_node_key,n.qualified_name,n.label,f.file_path " ++ + "FROM cbm_nodes_v1 n JOIN cbm_files_v1 f ON f.file_key=n.file_key " ++ + "WHERE n.workspace_key=?1 ORDER BY n.zova_node_key") orelse return ERR; + defer repoFinalize(statement); + if (!repoBindI64(statement, 1, repo.workspace_key)) return ERR; + var capacity: c_int = 0; + while (true) { + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoStep(statement, &step)) return ERR; + if (step != c.ZOVA_STEP_ROW) return OK; + if (@as(u64, @intCast(count.*)) >= max_nodes) return SCAN_LIMIT; + if (count.* == capacity) { + const grown_capacity: c_int = if (capacity != 0) capacity * 2 else 256; + const grown = interop.reallocN(IdentityNode, nodes.*, @intCast(grown_capacity)); + if (grown == null) return ERR; + nodes.* = grown; + capacity = grown_capacity; + } + const node = &many(nodes.*, capacity)[@intCast(count.*)]; + node.node_key = repoColumnI64(statement, 0); + node.qualified_name = repoColumnText(statement, 1); + node.label = repoColumnText(statement, 2); + node.file_path = repoColumnText(statement, 3); + count.* += 1; + if (node.qualified_name == null or node.label == null or node.file_path == null) return ERR; + } +} + +pub export fn cbm_zova_repository_graph_identities(maybe_repo: ?*Repository, workspace_id: ?Str, max_nodes: u64, max_edges: u64, maybe_out_nodes: ?*[*c]IdentityNode, maybe_node_count: ?*c_int, maybe_out_edges: ?*[*c]IdentityEdge, maybe_edge_count: ?*c_int) c_int { + if (!with_zova) { + if (maybe_out_nodes) |out_nodes| out_nodes.* = null; + if (maybe_node_count) |node_count| node_count.* = 0; + if (maybe_out_edges) |out_edges| out_edges.* = null; + if (maybe_edge_count) |edge_count| edge_count.* = 0; + return ERR; + } + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const out_nodes = maybe_out_nodes orelse return ERR; + const node_count = maybe_node_count orelse return ERR; + const out_edges = maybe_out_edges orelse return ERR; + const edge_count = maybe_edge_count orelse return ERR; + out_nodes.* = null; + node_count.* = 0; + out_edges.* = null; + edge_count.* = 0; + var nodes: [*c]IdentityNode = null; + var count: c_int = 0; + var edges = IdentityEdges{ .max_edges = max_edges }; + var rc = repoIdentityNodes(repo, max_nodes, &nodes, &count); + if (rc == OK) { + rc = repoScanEdges(repo, null, repoCollectIdentityEdge, &edges); + if (edges.over_limit) { + rc = SCAN_LIMIT; + } else if (rc != OK) { + rc = ERR; + } + } + if (rc != OK) { + cbm_zova_identities_free(nodes, count, edges.items, edges.count); + return rc; + } + out_nodes.* = nodes; + node_count.* = count; + out_edges.* = edges.items; + edge_count.* = edges.count; + return OK; +} + +pub export fn cbm_zova_identities_free(nodes: [*c]IdentityNode, node_count: c_int, edges: [*c]IdentityEdge, edge_count: c_int) void { + if (with_zova) { + for (many(nodes, node_count)) |*node| { + c.free(node.qualified_name); + c.free(node.label); + c.free(node.file_path); + } + for (many(edges, edge_count)) |*edge| { + c.free(edge.type); + c.free(edge.local_name); + } + } + c.free(nodes); + c.free(edges); +} + +// ── Workspace reads: search ───────────────────────────────────── + +/// Search rows read from SQL with the Zova key of each, before the graph +/// read that completes them. +const SearchRows = struct { + results: [*c]SearchResult = null, + keys: [*c]i64 = null, + count: c_int = 0, + + fn freeResults(rows: *SearchRows) void { + var owned = SearchOutput{ .results = rows.results, .count = rows.count, .total = 0 }; + c.cbm_store_search_free(&owned); + } +}; + +/// Collects the rows of a statement that selects the node key, label, name, +/// qualified name, file path, start line, end line and properties, then the +/// rank when `rank_column` is given. +fn repoReadSearchRows(repo: *const Repository, statement: ?*Stmt, initial_capacity: usize, rank_column: ?c_int, rows: *SearchRows) Error!void { + var cap = initial_capacity; + rows.results = interop.callocN(SearchResult, cap); + rows.keys = interop.callocN(i64, cap); + if (rows.results == null or rows.keys == null) return error.Failed; + while (try repoNextRow(statement)) { + const index: usize = @intCast(rows.count); + if (index == cap) { + cap *= 2; + const grown_results = interop.reallocN(SearchResult, rows.results, cap); + if (grown_results != null) rows.results = grown_results; + const grown_keys = interop.reallocN(i64, rows.keys, cap); + if (grown_keys != null) rows.keys = grown_keys; + if (grown_results == null or grown_keys == null) return error.Failed; + @memset(grown_results[index..cap], std.mem.zeroes(SearchResult)); + } + rows.keys[index] = repoColumnI64(statement, 0); + const result = &many(rows.results, cap)[index]; + const node = &result.node; + node.project = repoProjectDup(repo); + node.label = repoColumnText(statement, 1); + node.name = repoColumnText(statement, 2); + node.qualified_name = repoColumnText(statement, 3); + node.file_path = repoColumnText(statement, 4); + node.start_line = repoColumnInt(statement, 5); + node.end_line = repoColumnInt(statement, 6); + node.properties_json = repoColumnText(statement, 7); + if (rank_column) |column| result.rank = repoColumnDouble(statement, column); + rows.count += 1; + } +} + +fn repoSearchFtsRead(repo: *Repository, query: Str, file_pattern: ?Str, limit: c_int, offset: c_int, rows: *SearchRows) Error!void { + { + const sql = "SELECT n.zova_node_key,n.label,n.name,n.qualified_name,p.file_path," ++ + "n.start_line,n.end_line,n.properties,(bm25(cbm_nodes_fts_v1)-CASE " ++ + "WHEN n.label IN ('Function','Method') THEN 10.0 WHEN n.label='Route' THEN 8.0 " ++ + "WHEN n.label IN ('Class','Interface','Type','Enum') THEN 5.0 ELSE 0.0 END) AS rank " ++ + "FROM cbm_nodes_fts_v1 f " ++ + "JOIN cbm_nodes_v1 n ON n.zova_node_key=f.rowid " ++ + "JOIN cbm_files_v1 p ON p.file_key=n.file_key " ++ + "WHERE n.workspace_key=?1 AND cbm_nodes_fts_v1 MATCH ?2 " ++ + "AND n.label NOT IN ('File','Folder','Module','Section','Variable','Project') " ++ + "AND (?3 IS NULL OR p.file_path GLOB ?3) " ++ + "ORDER BY rank,n.qualified_name,n.zova_node_key LIMIT ?4 OFFSET ?5"; + const statement = repoPrepare(repo.db, sql) orelse return error.Failed; + defer repoFinalize(statement); + if (!repoBindI64(statement, 1, repo.workspace_key) or !repoBindText(statement, 2, query)) return error.Failed; + const pattern_bound = if (nonEmpty(file_pattern)) |pattern| + repoBindText(statement, 3, pattern) + else + repoBindNull(statement, 3); + if (!pattern_bound or !repoBindI64(statement, 4, limit) or !repoBindI64(statement, 5, offset)) return error.Failed; + try repoReadSearchRows(repo, statement, 8, 8, rows); + } + const count: usize = @intCast(rows.count); + var native = std.mem.zeroes(KeyedNodes); + defer c.zova_graph_keyed_node_results_free(&native); + if (!repoNativeNodesByKey(repo, rows.keys, count, &native)) return error.Failed; + for (many(rows.results, count), many(native.items, count)) |*result, *item| { + if (item.found == 0) return error.Failed; + result.node.id = stableNumericId(item.node_id); + freeConst(result.node.label); + result.node.label = repoDup(item.kind, item.kind_len); + } +} + +pub export fn cbm_zova_repository_search_fts(maybe_repo: ?*Repository, workspace_id: ?Str, maybe_query: ?Str, file_pattern: ?Str, limit: c_int, offset: c_int, maybe_out: ?*SearchOutput) c_int { + if (!with_zova) return ERR; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const query = nonEmpty(maybe_query) orelse return ERR; + const out = maybe_out orelse return ERR; + out.* = std.mem.zeroes(SearchOutput); + var rows = SearchRows{}; + defer c.free(rows.keys); + repoSearchFtsRead(repo, query, file_pattern, if (limit <= 0) 10 else limit, offset, &rows) catch { + rows.freeResults(); + return ERR; + }; + out.results = rows.results; + out.count = rows.count; + out.total = rows.count; + return OK; +} + +/// A fixed SQL buffer filled the way strncat() fills one: a clause that +/// does not fit is cut, and the text stays NUL-terminated. +const SqlBuffer = struct { + text: [4096]u8 = undefined, + len: usize = 0, + + fn append(sql: *SqlBuffer, clause: []const u8) void { + const n = @min(clause.len, sql.text.len - sql.len - 1); + @memcpy(sql.text[sql.len..][0..n], clause[0..n]); + sql.len += n; + sql.text[sql.len] = 0; + } +}; + +/// Reads the degrees of `keys` for one edge type and direction into `tmp`, +/// adds them to `sum`, and copies them to `copy` when given. +fn repoAddDegrees(repo: *const Repository, keys: [*c]const i64, direction: c_int, edge_type: CStr, tmp: []u64, sum: []u64, copy: ?[]u64) bool { + @memset(tmp, 0); + if (!repoDegreeMany(repo, keys, tmp.len, direction, edge_type, tmp.ptr)) return false; + for (sum, tmp) |*total, degree| total.* +%= degree; + if (copy) |first| @memcpy(first, tmp); + return true; +} + +/// Runs the structured search: reads every matching row into `rows`, then +/// moves the rows that pass the degree filters and fall in the requested +/// page into `page`. The caller owns both whatever the result. +fn repoSearchRead(repo: *Repository, params: *const SearchParams, rows: *SearchRows, page: *SearchOutput) Error!void { + var sql = SqlBuffer{}; + sql.append("SELECT n.zova_node_key,n.label,n.name,n.qualified_name,f.file_path," ++ + "n.start_line,n.end_line,n.properties FROM cbm_nodes_v1 n " ++ + "JOIN cbm_files_v1 f ON f.file_key=n.file_key WHERE n.workspace_key=?1"); + var binds: [64]CStr = undefined; + var bind_count: usize = 0; + const filters = [_]struct { clause: []const u8, value: CStr }{ + .{ .clause = " AND n.label=?", .value = params.label }, + .{ .clause = if (params.case_sensitive) " AND regexp(?,n.name)" else " AND iregexp(?,n.name)", .value = params.name_pattern }, + .{ .clause = if (params.case_sensitive) " AND regexp(?,n.qualified_name)" else " AND iregexp(?,n.qualified_name)", .value = params.qn_pattern }, + .{ .clause = " AND f.file_path GLOB ?", .value = params.file_pattern }, + }; + for (filters) |filter| { + if (filter.value == null or filter.value[0] == 0) continue; + sql.append(filter.clause); + binds[bind_count] = filter.value; + bind_count += 1; + } + if (params.exclude_labels != null) { + var i: usize = 0; + while (params.exclude_labels[i] != null and bind_count < 63) : (i += 1) { + sql.append(" AND n.label<>?"); + binds[bind_count] = params.exclude_labels[i]; + bind_count += 1; + } + } + sql.append(" ORDER BY n.name,n.qualified_name,n.zova_node_key"); + { + const statement = repoPrepare(repo.db, @ptrCast(&sql.text)) orelse return error.Failed; + defer repoFinalize(statement); + if (!repoBindI64(statement, 1, repo.workspace_key)) return error.Failed; + for (binds[0..bind_count], 2..) |value, index| { + if (!repoBindText(statement, @intCast(index), value)) return error.Failed; + } + try repoReadSearchRows(repo, statement, 64, null, rows); + } + + const count: usize = @intCast(rows.count); + var native = std.mem.zeroes(KeyedNodes); + defer c.zova_graph_keyed_node_results_free(&native); + // Seven arrays of `count` degrees in one block. + const block = interop.callocN(u64, count * 7); + defer c.free(block); + if (block == null) return error.Failed; + const degrees = block[0 .. count * 7]; + const in = degrees[0..count]; + const outd = degrees[count..][0..count]; + const tmp = degrees[count * 2 ..][0..count]; + const calls_in = degrees[count * 3 ..][0..count]; + const calls_out = degrees[count * 4 ..][0..count]; + const rel_in = degrees[count * 5 ..][0..count]; + const rel_out = degrees[count * 6 ..][0..count]; + if (!repoNativeNodesByKey(repo, rows.keys, count, &native)) return error.Failed; + for ([_]Str{ "CALLS", "USAGE", "CALL_REFERENCE", "INHERITS", "IMPLEMENTS" }, 0..) |degree_type, t| { + if (!repoAddDegrees(repo, rows.keys, c.ZOVA_GRAPH_NEIGHBOR_INCOMING, degree_type, tmp, in, if (t == 0) calls_in else null) or + !repoAddDegrees(repo, rows.keys, c.ZOVA_GRAPH_NEIGHBOR_OUTGOING, degree_type, tmp, outd, if (t == 0) calls_out else null)) + return error.Failed; + } + if (params.relationship != null) { + if (!repoDegreeMany(repo, rows.keys, count, c.ZOVA_GRAPH_NEIGHBOR_INCOMING, params.relationship, rel_in.ptr) or + !repoDegreeMany(repo, rows.keys, count, c.ZOVA_GRAPH_NEIGHBOR_OUTGOING, params.relationship, rel_out.ptr)) + return error.Failed; + } + const limit: c_int = if (params.limit > 0) params.limit else 10; + const page_capacity: usize = @intCast(@min(limit, rows.count)); + page.results = interop.callocN(SearchResult, page_capacity); + if (page_capacity != 0 and page.results == null) return error.Failed; + var accepted: c_int = 0; + for (many(rows.results, count), many(native.items, count), 0..) |*result, *item, i| { + if (item.found == 0) return error.Failed; + const degree = narrowInt(in[i] +% outd[i]); + var keep = true; + if (params.relationship != null) { + keep = if (params.direction == null or c.strcmp(params.direction, "any") == 0) + rel_in[i] +% rel_out[i] > 0 + else if (c.strcmp(params.direction, "inbound") == 0) + rel_in[i] > 0 + else + rel_out[i] > 0; + } + if (keep and params.exclude_entry_points and calls_in[i] == 0 and calls_out[i] > 0) keep = false; + if (keep and params.min_degree >= 0 and degree < params.min_degree) keep = false; + if (keep and params.max_degree >= 0 and degree > params.max_degree) keep = false; + if (!keep) continue; + if (accepted >= params.offset and page.count < limit) { + result.node.id = stableNumericId(item.node_id); + freeConst(result.node.label); + result.node.label = repoDup(item.kind, item.kind_len); + result.in_degree = narrowInt(in[i]); + result.out_degree = narrowInt(outd[i]); + page.results[@intCast(page.count)] = result.*; + page.count += 1; + result.* = std.mem.zeroes(SearchResult); + } + accepted += 1; + } + page.total = accepted; +} + +pub export fn cbm_zova_repository_search(maybe_repo: ?*Repository, workspace_id: ?Str, maybe_params: ?*const SearchParams, maybe_out: ?*SearchOutput) c_int { + if (!with_zova) return ERR; + const repo = repoWorkspaceRequired(maybe_repo, workspace_id) orelse return ERR; + const params = maybe_params orelse return ERR; + const out = maybe_out orelse return ERR; + out.* = std.mem.zeroes(SearchOutput); + var rows = SearchRows{}; + defer { + c.free(rows.keys); + rows.freeResults(); + } + var page = std.mem.zeroes(SearchOutput); + repoSearchRead(repo, params, &rows, &page) catch { + c.cbm_store_search_free(&page); + return ERR; + }; + out.* = page; + return OK; +} + +// ── Workspace snapshots ───────────────────────────────────────── + +const DumpNode = c.CBMDumpNode; +const DumpEdge = c.CBMDumpEdge; +const VectorInfo = c.zova_vector_collection_info; +const Digest = [c.CBM_ZOVA_DIGEST_HEX_SIZE]u8; + +const COMPONENT_NONE: Components = c.CBM_ZOVA_SNAPSHOT_COMPONENT_NONE; +const COMPONENT_TOPOLOGY: Components = c.CBM_ZOVA_SNAPSHOT_COMPONENT_TOPOLOGY; +const COMPONENT_NODE_VECTORS: Components = c.CBM_ZOVA_SNAPSHOT_COMPONENT_NODE_VECTORS; +const COMPONENT_TOKEN_VECTORS: Components = c.CBM_ZOVA_SNAPSHOT_COMPONENT_TOKEN_VECTORS; +const COMPONENT_ALL: Components = c.CBM_ZOVA_SNAPSHOT_COMPONENT_ALL; + +const PHASE_OPEN: u32 = c.CBM_ZOVA_SNAPSHOT_BASE_PHASE_OPEN; +const PHASE_HEADER: u32 = c.CBM_ZOVA_SNAPSHOT_BASE_PHASE_HEADER; +const PHASE_INTEGRITY: u32 = c.CBM_ZOVA_SNAPSHOT_BASE_PHASE_INTEGRITY; +const PHASE_NODES: u32 = c.CBM_ZOVA_SNAPSHOT_BASE_PHASE_NODES; +const PHASE_EDGES: u32 = c.CBM_ZOVA_SNAPSHOT_BASE_PHASE_EDGES; +const PHASE_HASHES_SUMMARY: u32 = c.CBM_ZOVA_SNAPSHOT_BASE_PHASE_HASHES_SUMMARY; +const PHASE_CLOSE: u32 = c.CBM_ZOVA_SNAPSHOT_BASE_PHASE_CLOSE; + +/// A zeroed array of `count` items; NULL for an empty one and on OOM. +fn snapshotAlloc(comptime T: type, count: usize) [*c]T { + return if (count != 0) interop.callocN(T, count) else null; +} + +/// Frees an array of `count` malloc'd strings and the array itself. +fn freeStrings(strings: [*c][*c]u8, count: c_int) void { + for (many(strings, count)) |string| c.free(string); + c.free(@ptrCast(strings)); +} + +fn snapshotRelease(snapshot: *Snapshot) void { + c.free(snapshot.root_path); + c.free(snapshot.project); + c.free(snapshot.indexed_at); + c.free(snapshot.model_fingerprint); + freeStrings(snapshot.node_stable_ids, snapshot.node_count); + for (many(snapshot.nodes, snapshot.node_count)) |*node| { + freeConst(node.project); + freeConst(node.label); + freeConst(node.name); + freeConst(node.qualified_name); + freeConst(node.file_path); + freeConst(node.properties); + } + c.free(snapshot.zova_node_keys); + c.free(snapshot.node_source_ordinals); + c.free(snapshot.nodes); + freeStrings(snapshot.edge_ids, snapshot.edge_count); + for (many(snapshot.edges, snapshot.edge_count)) |*edge| { + // Edges read from the database share the snapshot's project string + // and keep their other strings in the arena. + if (edge.project != snapshot.project) freeConst(edge.project); + if (snapshot.edge_string_arena == null) { + freeConst(edge.type); + freeConst(edge.properties); + freeConst(edge.url_path); + freeConst(edge.local_name); + } + } + c.free(snapshot.edges); + repoStringArenaFree(@ptrCast(@alignCast(snapshot.edge_string_arena))); + for (many(snapshot.topology_edges, snapshot.topology_edge_count)) |*edge| { + c.free(edge.source_stable_id); + c.free(edge.edge_type); + c.free(edge.target_stable_id); + } + c.free(snapshot.topology_edges); + freeStrings(snapshot.node_vector_ids, snapshot.node_vector_count); + for (many(snapshot.node_vectors, snapshot.node_vector_count)) |*vector| { + freeConst(vector.project); + freeConst(vector.vector); + } + c.free(snapshot.node_vectors); + freeStrings(snapshot.token_vector_ids, snapshot.token_vector_count); + for (many(snapshot.token_vectors, snapshot.token_vector_count)) |*vector| { + freeConst(vector.project); + freeConst(vector.token); + freeConst(vector.vector); + } + c.free(snapshot.token_vectors); + for (many(snapshot.file_hashes, snapshot.file_hash_count)) |*hash| { + freeConst(hash.file_path); + freeConst(hash.content_hash); + } + c.free(snapshot.file_hashes); + freeConst(snapshot.project_summary.summary); + freeConst(snapshot.project_summary.source_hash); + freeConst(snapshot.project_summary.created_at); + freeConst(snapshot.project_summary.updated_at); +} + +pub export fn cbm_zova_workspace_snapshot_free(maybe_snapshot: ?*Snapshot) void { + const snapshot = maybe_snapshot orelse return; + if (with_zova) snapshotRelease(snapshot); + snapshot.* = std.mem.zeroes(Snapshot); +} + +/// The single count a `SELECT count(*) ... WHERE workspace_key=?1` returns; +/// -1 on failure. +fn snapshotCount(repo: *const Repository, sql: Str) c_int { + const statement = repoPrepare(repo.db, sql) orelse return -1; + defer repoFinalize(statement); + if (!repoBindI64(statement, 1, repo.workspace_key)) return -1; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + return if (repoStep(statement, &step) and step == c.ZOVA_STEP_ROW) repoColumnInt(statement, 0) else -1; +} + +/// Maps a Zova node key to the 1-based id the node has in the snapshot. +const SnapshotNodeKeyRef = extern struct { + node_key: i64, + snapshot_id: i64, +}; + +fn snapshotNodeKeyRefCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + const left = cast(SnapshotNodeKeyRef, left_ptr).node_key; + const right = cast(SnapshotNodeKeyRef, right_ptr).node_key; + return @as(c_int, @intFromBool(left > right)) - @intFromBool(left < right); +} + +/// The snapshot id of `node_key` in refs sorted by key; -1 when absent. +fn snapshotMapNodeKey(refs: []const SnapshotNodeKeyRef, node_key: i64) i64 { + var low: usize = 0; + var high = refs.len; + while (low < high) { + const mid = low + (high - low) / 2; + if (refs[mid].node_key == node_key) return refs[mid].snapshot_id; + if (refs[mid].node_key < node_key) low = mid + 1 else high = mid; + } + return -1; +} + +const SnapshotNodeRow = extern struct { + node_key: i64, + stable_id: [*c]u8, + node: DumpNode, + source_ordinal: u64, +}; + +fn snapshotNodeRowCompare(left: ?*const anyopaque, right: ?*const anyopaque) callconv(.c) c_int { + return c.strcmp(cast(SnapshotNodeRow, left).stable_id, cast(SnapshotNodeRow, right).stable_id); +} + +/// Reads every node of the workspace into the snapshot, ordered by stable id +/// and numbered from 1. `out_node_keys` receives the key-to-id map, sorted by +/// key, which the caller frees. +fn snapshotReadNodes(repo: *Repository, snapshot: *Snapshot, out_node_keys: *[*c]SnapshotNodeKeyRef) Error!void { + var phase_started = repoNow(); + const signed_count = snapshotCount(repo, "SELECT count(*) FROM cbm_nodes_v1 WHERE workspace_key=?1"); + if (signed_count < 0) return error.Failed; + const count: usize = @intCast(signed_count); + snapshot.node_count = signed_count; + snapshot.nodes = snapshotAlloc(DumpNode, count); + const stable_ids = snapshotAlloc([*c]u8, count); + errdefer c.free(@ptrCast(stable_ids)); + const node_keys = snapshotAlloc(SnapshotNodeKeyRef, count); + errdefer c.free(node_keys); + const rows_ptr = snapshotAlloc(SnapshotNodeRow, count); + defer c.free(rows_ptr); + const keys = snapshotAlloc(i64, count); + defer c.free(keys); + snapshot.node_source_ordinals = snapshotAlloc(u64, count); + snapshot.zova_node_keys = snapshotAlloc(i64, count); + if (count != 0 and (snapshot.nodes == null or stable_ids == null or node_keys == null or + rows_ptr == null or keys == null or snapshot.node_source_ordinals == null or + snapshot.zova_node_keys == null)) return error.Failed; + const rows = many(rows_ptr, count); + // Until the rows move into the snapshot they own their strings. + errdefer for (rows) |*row| { + c.free(row.stable_id); + freeConst(row.node.project); + freeConst(row.node.label); + freeConst(row.node.name); + freeConst(row.node.qualified_name); + freeConst(row.node.file_path); + freeConst(row.node.properties); + }; + { + const statement = repoPrepare(repo.db, "SELECT n.zova_node_key,n.name,n.qualified_name,f.file_path,n.start_line," ++ + "n.end_line,n.properties,n.source_ordinal FROM cbm_nodes_v1 n " ++ + "JOIN cbm_files_v1 f ON f.file_key=n.file_key " ++ + "WHERE n.workspace_key=?1 ORDER BY n.zova_node_key") orelse return error.Failed; + defer repoFinalize(statement); + if (!repoBindI64(statement, 1, repo.workspace_key)) return error.Failed; + for (rows, many(keys, count)) |*row, *key| { + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoStep(statement, &step) or step != c.ZOVA_STEP_ROW) return error.Failed; + const node = &row.node; + row.node_key = repoColumnI64(statement, 0); + key.* = row.node_key; + node.project = repoDupZ(snapshot.project); + node.name = repoColumnText(statement, 1); + node.qualified_name = repoColumnText(statement, 2); + node.file_path = repoColumnText(statement, 3); + node.start_line = repoColumnInt(statement, 4); + node.end_line = repoColumnInt(statement, 5); + node.properties = repoColumnText(statement, 6); + row.source_ordinal = @bitCast(repoColumnI64(statement, 7)); + if (node.project == null or node.name == null or node.qualified_name == null or + node.file_path == null or node.properties == null or row.node_key <= 0) return error.Failed; + } + } + snapshot.metrics.nodes_sql_ms = repoElapsedMs(phase_started); + phase_started = repoNow(); + var native = std.mem.zeroes(KeyedNodes); + defer c.zova_graph_keyed_node_results_free(&native); + if (!repoNativeNodesByKey(repo, keys, count, &native)) return error.Failed; + snapshot.metrics.nodes_native_ms = repoElapsedMs(phase_started); + phase_started = repoNow(); + for (rows, many(native.items, count), 0..) |*row, *item, i| { + if (item.found == 0 or item.node_key != row.node_key or item.node_id == null or item.kind == null) { + _ = c.fprintf(interop.stderr(), "zova keyed node mismatch index=%d requested=%lld found=%u returned=%lld\n", @as(c_int, @intCast(i)), @as(c_longlong, row.node_key), @as(c_uint, item.found), @as(c_longlong, item.node_key)); + return error.Failed; + } + row.stable_id = repoDup(item.node_id, item.node_id_len); + row.node.label = repoDup(item.kind, item.kind_len); + if (row.stable_id == null or row.node.label == null) return error.Failed; + } + if (count > 1) c.qsort(rows.ptr, count, @sizeOf(SnapshotNodeRow), snapshotNodeRowCompare); + for (rows, 0..) |*row, i| { + const id: i64 = @intCast(i + 1); + row.node.id = id; + snapshot.nodes[i] = row.node; + stable_ids[i] = row.stable_id; + snapshot.zova_node_keys[i] = row.node_key; + snapshot.node_source_ordinals[i] = row.source_ordinal; + node_keys[i] = .{ .node_key = row.node_key, .snapshot_id = id }; + } + if (count > 1) c.qsort(node_keys, count, @sizeOf(SnapshotNodeKeyRef), snapshotNodeKeyRefCompare); + snapshot.node_stable_ids = stable_ids; + out_node_keys.* = node_keys; + snapshot.metrics.nodes_finalize_ms = repoElapsedMs(phase_started); + snapshot.metrics.node_rows = count; + snapshot.metrics.base_phase_mask |= PHASE_NODES; +} + +const SnapshotEdgeSortRow = extern struct { + edge: DumpEdge, + edge_id: [*c]u8, +}; + +fn snapshotEdgeSortRowCompare(left: ?*const anyopaque, right: ?*const anyopaque) callconv(.c) c_int { + return c.strcmp(cast(SnapshotEdgeSortRow, left).edge_id, cast(SnapshotEdgeSortRow, right).edge_id); +} + +/// Where snapshotAppendPayloadEdge puts the records of one graph edge. +const SnapshotPayloadContext = struct { + snapshot: *Snapshot, + arena: ?*StringArena, + node_keys: []const SnapshotNodeKeyRef, + native: *const ScanEdge, + expected_count: c_int, + written: *c_int, +}; + +fn snapshotAppendPayloadEdge(maybe_record: [*c]const PayloadRecord, context_ptr: ?*anyopaque) callconv(.c) c_int { + if (maybe_record == null) return -1; + const record: *const PayloadRecord = @ptrCast(maybe_record); + const context: *SnapshotPayloadContext = @ptrCast(@alignCast(context_ptr orelse return -1)); + if (context.written.* >= context.expected_count) return -1; + const source_id = snapshotMapNodeKey(context.node_keys, context.native.source_node_key); + const target_id = snapshotMapNodeKey(context.node_keys, context.native.target_node_key); + if (source_id <= 0 or target_id <= 0) return -1; + const edge = &many(context.snapshot.edges, context.expected_count)[@intCast(context.written.*)]; + edge.* = .{ + .id = context.written.* + 1, + .project = context.snapshot.project, + .source_id = source_id, + .target_id = target_id, + .type = repoStringArenaDup(context.arena, context.native.edge_type, context.native.edge_type_len), + .properties = repoStringArenaDup(context.arena, record.properties.data, record.properties.len), + .url_path = repoStringArenaDup(context.arena, record.url_path.data, record.url_path.len), + .local_name = repoStringArenaDup(context.arena, record.local_name.data, record.local_name.len), + }; + if (edge.type == null or edge.properties == null or edge.url_path == null or edge.local_name == null) return -1; + context.written.* += 1; + return 0; +} + +/// Reads every edge record of the workspace graph into snapshot.edges in +/// scan order. On failure `failure` names the stage, when the stage has a +/// name. +fn snapshotScanEdges(repo: *Repository, snapshot: *Snapshot, node_keys: []const SnapshotNodeKeyRef, written: *c_int, failure: *?Str) bool { + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (!repoGraphName(repo, &graph_name)) { + failure.* = "graph_name"; + return false; + } + var cursor = std.mem.zeroes(ScanCursor); + while (true) { + var page = std.mem.zeroes(ScanResults); + defer c.zova_graph_scan_results_free(&page); + var payloads = std.mem.zeroes(EdgePayloads); + defer c.zova_graph_edge_payload_results_free(&payloads); + const status = c.zova_graph_scan(&c.zova_graph_scan_request{ + .db = repo.db, + .graph_name = &graph_name, + .edge_after = cursor, + .node_limit = 0, + .edge_limit = EDGE_SCAN_PAGE, + .out_results = &page, + }); + if (status != c.ZOVA_OK) { + _ = c.fprintf(interop.stderr(), "zova snapshot graph scan failed status=%s\n", c.zova_status_name(status)); + failure.* = "graph_scan"; + return false; + } + const page_edges = many(page.edges, page.edges_len); + if (page_edges.len != 0) { + snapshot.metrics.edge_scan_pages += 1; + snapshot.metrics.edge_native_rows += page_edges.len; + const edge_keys = interop.callocN(i64, page_edges.len); + if (edge_keys == null) return false; + defer c.free(edge_keys); + for (page_edges, edge_keys[0..page_edges.len]) |*edge, *key| key.* = edge.edge_key; + if (c.zova_graph_edge_payload_get_many(&c.zova_graph_edge_payload_get_many_request{ + .db = repo.db, + .graph_name = &graph_name, + .edge_keys = edge_keys, + .key_count = page_edges.len, + .out_results = &payloads, + }) != c.ZOVA_OK) return false; + } + if (payloads.len != page_edges.len) return false; + snapshot.metrics.edge_keyed_read_count += payloads.len; + for (page_edges, many(payloads.items, page_edges.len)) |*native, *payload| { + if (payload.found == 0 or payload.edge_key != native.edge_key) { + failure.* = "payload_identity"; + return false; + } + var context = SnapshotPayloadContext{ + .snapshot = snapshot, + .arena = @ptrCast(@alignCast(snapshot.edge_string_arena)), + .node_keys = node_keys, + .native = native, + .expected_count = snapshot.edge_count, + .written = written, + }; + if (c.cbm_zova_edge_payload_visit(payload.payload, payload.payload_len, snapshotAppendPayloadEdge, &context, null) != 0) { + failure.* = "payload_decode"; + return false; + } + } + if (page_edges.len != 0) { + const last = &page_edges[page_edges.len - 1]; + cursor = .{ .created_order = last.created_order, .key = last.edge_key }; + } + if (page.has_more_edges == 0) return true; + } +} + +/// Reads every edge of the workspace into the snapshot, ordered by edge id +/// and numbered from 1. The edge strings live in snapshot.edge_string_arena. +fn snapshotReadEdges(repo: *Repository, snapshot: *Snapshot, node_keys: []const SnapshotNodeKeyRef) Error!void { + var phase_started = repoNow(); + if (snapshot.integrity.metadata_edges > INT_MAX) return error.Failed; + const count: usize = @intCast(snapshot.integrity.metadata_edges); + snapshot.edge_count = @intCast(count); + snapshot.edges = snapshotAlloc(DumpEdge, count); + snapshot.edge_ids = snapshotAlloc([*c]u8, count); + const arena: ?*StringArena = snapshotAlloc(StringArena, @intFromBool(count != 0)); + snapshot.edge_string_arena = arena; + if (count != 0 and (snapshot.edges == null or snapshot.edge_ids == null or arena == null)) return error.Failed; + snapshot.metrics.edges_sql_ms = repoElapsedMs(phase_started); + phase_started = repoNow(); + var written: c_int = 0; + var failure: ?Str = null; + var scanned = snapshotScanEdges(repo, snapshot, node_keys, &written, &failure); + snapshot.metrics.edge_logical_rows = @intCast(written); + if (scanned and written != snapshot.edge_count) scanned = false; + if (!scanned) { + if (failure) |stage| _ = c.fprintf(interop.stderr(), "zova snapshot edge read failed stage=%s\n", stage); + return error.Failed; + } + snapshot.metrics.edges_native_ms = repoElapsedMs(phase_started); + phase_started = repoNow(); + const rows_ptr = snapshotAlloc(SnapshotEdgeSortRow, count); + if (count != 0 and rows_ptr == null) return error.Failed; + const rows = many(rows_ptr, count); + defer { + for (rows) |*row| c.free(row.edge_id); + c.free(rows_ptr); + } + const edges = many(snapshot.edges, count); + for (rows, edges) |*row, *edge| { + var edge_id: [ID_MAX + 80]u8 = undefined; + if (!repoEdgeId(&snapshot.workspace_id, snapshot.node_stable_ids[@intCast(edge.source_id - 1)], edge.type, snapshot.node_stable_ids[@intCast(edge.target_id - 1)], edge.local_name, &edge_id)) return error.Failed; + row.edge_id = repoDupZ(&edge_id); + if (row.edge_id == null) return error.Failed; + row.edge = edge.*; + } + if (count > 1) c.qsort(rows.ptr, count, @sizeOf(SnapshotEdgeSortRow), snapshotEdgeSortRowCompare); + for (rows, edges, many(snapshot.edge_ids, count), 1..) |*row, *edge, *edge_id, id| { + edge.* = row.edge; + edge.id = @intCast(id); + edge_id.* = row.edge_id; + row.edge_id = null; + } + snapshot.metrics.edges_finalize_ms = repoElapsedMs(phase_started); + snapshot.metrics.edge_rows = count; + if (arena) |strings| { + snapshot.metrics.edge_string_arena_chunks = strings.chunk_count; + snapshot.metrics.edge_string_arena_bytes = strings.bytes_used; + } + snapshot.metrics.base_phase_mask |= PHASE_EDGES; +} + +pub export fn cbm_zova_workspace_snapshot_format_edge_id(maybe_snapshot: ?*const Snapshot, edge_index: c_int, maybe_out: ?[*]u8, out_size: usize) c_int { + if (!with_zova) { + if (maybe_out) |out| { + if (out_size != 0) out[0] = 0; + } + return -1; + } + const snapshot = maybe_snapshot orelse return -1; + if (edge_index < 0 or edge_index >= snapshot.edge_count) return -1; + const out = (maybe_out orelse return -1)[0..out_size]; + if (out.len == 0) return -1; + const index: usize = @intCast(edge_index); + if (snapshot.edge_ids != null and snapshot.edge_ids[index] != null) { + return if (interop.bprint(out, "{s}", .{span(snapshot.edge_ids[index])}) < out.len) 0 else -1; + } + const edge = &many(snapshot.edges, snapshot.edge_count)[index]; + if (edge.source_id <= 0 or edge.source_id > snapshot.node_count or + edge.target_id <= 0 or edge.target_id > snapshot.node_count or edge.type == null) return -1; + const local_name: CStr = if (edge.local_name != null) edge.local_name else ""; + return if (repoEdgeId(&snapshot.workspace_id, snapshot.node_stable_ids[@intCast(edge.source_id - 1)], edge.type, snapshot.node_stable_ids[@intCast(edge.target_id - 1)], local_name, out)) 0 else -1; +} + +const SnapshotTopologyRef = extern struct { + source_stable_id: CStr, + edge_type: CStr, + target_stable_id: CStr, +}; + +fn snapshotTopologyRefCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + const left = cast(SnapshotTopologyRef, left_ptr); + const right = cast(SnapshotTopologyRef, right_ptr); + var order = c.strcmp(left.source_stable_id, right.source_stable_id); + if (order == 0) order = c.strcmp(left.edge_type, right.edge_type); + if (order == 0) order = c.strcmp(left.target_stable_id, right.target_stable_id); + return order; +} + +/// Builds the distinct (source, type, target) edges of `snapshot` into +/// `out`, which may be the same snapshot. +fn snapshotBuildTopology(snapshot: *const Snapshot, out: *Snapshot) bool { + if (snapshot.edge_count < 0) return false; + const count: usize = @intCast(snapshot.edge_count); + const refs_ptr = snapshotAlloc(SnapshotTopologyRef, count); + defer c.free(refs_ptr); + if (count != 0 and refs_ptr == null) return false; + const refs = many(refs_ptr, count); + for (refs, many(snapshot.edges, count)) |*ref, *edge| { + if (edge.source_id <= 0 or edge.source_id > snapshot.node_count or + edge.target_id <= 0 or edge.target_id > snapshot.node_count or edge.type == null) return false; + ref.* = .{ + .source_stable_id = snapshot.node_stable_ids[@intCast(edge.source_id - 1)], + .edge_type = edge.type, + .target_stable_id = snapshot.node_stable_ids[@intCast(edge.target_id - 1)], + }; + if (ref.source_stable_id == null or ref.target_stable_id == null) return false; + } + if (count > 1) c.qsort(refs.ptr, count, @sizeOf(SnapshotTopologyRef), snapshotTopologyRefCompare); + var unique_count: usize = 0; + for (refs, 0..) |*ref, i| { + if (i == 0 or snapshotTopologyRefCompare(&refs[i - 1], ref) != 0) unique_count += 1; + } + if (unique_count != snapshot.integrity.metadata_topology_edges) return false; + out.topology_edges = snapshotAlloc(c.cbm_zova_snapshot_topology_edge_t, unique_count); + if (unique_count != 0 and out.topology_edges == null) return false; + out.topology_edge_count = @intCast(unique_count); + const rows = many(out.topology_edges, unique_count); + var written: usize = 0; + for (refs, 0..) |*ref, i| { + if (i > 0 and snapshotTopologyRefCompare(&refs[i - 1], ref) == 0) continue; + const row = &rows[written]; + written += 1; + row.source_stable_id = repoDupZ(ref.source_stable_id); + row.edge_type = repoDupZ(ref.edge_type); + row.target_stable_id = repoDupZ(ref.target_stable_id); + if (row.source_stable_id == null or row.edge_type == null or row.target_stable_id == null) return false; + } + return true; +} + +/// The size of the int8 vector collection `name`, when it has the snapshot's +/// dimensions and exactly `expected` vectors; null otherwise. +fn snapshotVectorCount(repo: *const Repository, name: Str, dimensions: c_int, expected: u64) ?c_int { + var info = std.mem.zeroes(VectorInfo); + if (c.zova_vector_collection_info_get(&c.zova_vector_collection_info_get_request{ + .db = repo.db, + .name = name, + .out_info = &info, + }) != c.ZOVA_OK) return null; + defer c.zova_vector_collection_info_free(&info); + if (info.element_type != c.ZOVA_VECTOR_ELEMENT_TYPE_I8 or + info.dimensions != @as(u32, @bitCast(dimensions)) or + info.vector_count > std.math.maxInt(i32) or info.vector_count != expected) return null; + return @intCast(info.vector_count); +} + +/// A malloc'd copy of the int8 values of vector `key` in `collection`; NULL +/// when the vector cannot be read or does not have `dimensions` values. +fn snapshotVectorBytes(repo: *const Repository, collection: Str, key: i64, dimensions: c_int) [*c]u8 { + var vector_id: [32]u8 = undefined; + if (interop.bprint(&vector_id, "{d}", .{key}) >= vector_id.len) return null; + var native = std.mem.zeroes(c.zova_vector); + defer c.zova_vector_free(&native); + if (c.zova_vector_get(&c.zova_vector_get_request{ + .db = repo.db, + .collection_name = collection, + .vector_id = &vector_id, + .out_vector = &native, + }) != c.ZOVA_OK or native.element_type != c.ZOVA_VECTOR_ELEMENT_TYPE_I8 or + native.values_len != @as(usize, @bitCast(@as(isize, dimensions)))) return null; + const bytes = interop.mallocN(u8, native.values_len); + if (bytes != null) _ = c.memcpy(bytes, native.i8_values, native.values_len); + return bytes; +} + +/// Reads the vector of every node of `snapshot` that has one into `out`, +/// which may be the same snapshot. +fn snapshotReadNodeVectors(repo: *Repository, snapshot: *const Snapshot, out: *Snapshot) bool { + var collection: [ID_MAX + 128]u8 = undefined; + if (c.cbm_zova_workspace_node_vector_collection_name(repo.workspaceId(), snapshot.model_fingerprint, snapshot.vector_dimensions, &collection, collection.len) != 0) return false; + const node_count = snapshotVectorCount(repo, @ptrCast(&collection), snapshot.vector_dimensions, snapshot.integrity.node_vectors) orelse return false; + const count: usize = @intCast(node_count); + out.node_vectors = snapshotAlloc(c.CBMDumpVector, count); + out.node_vector_ids = snapshotAlloc([*c]u8, count); + out.node_vector_count = node_count; + if (count != 0 and (out.node_vectors == null or out.node_vector_ids == null)) return false; + const vectors = many(out.node_vectors, count); + const vector_ids = many(out.node_vector_ids, count); + var written: usize = 0; + for (many(snapshot.nodes, snapshot.node_count), 0..) |*node, i| { + if (snapshot.zova_node_keys == null) return false; + const key = snapshot.zova_node_keys[i]; + var vector_id: [32]u8 = undefined; + if (interop.bprint(&vector_id, "{d}", .{key}) >= vector_id.len) return false; + var exists: u8 = 0; + if (c.zova_vector_exists(&c.zova_vector_exists_request{ + .db = repo.db, + .collection_name = &collection, + .vector_id = &vector_id, + .out_exists = &exists, + }) != c.ZOVA_OK) return false; + if (exists == 0) continue; + if (written >= count) return false; + const bytes = snapshotVectorBytes(repo, @ptrCast(&collection), key, snapshot.vector_dimensions); + if (bytes == null) return false; + const vector = &vectors[written]; + vector_ids[written] = repoDupZ(snapshot.node_stable_ids[i]); + vector.* = .{ + .node_id = node.id, + .project = repoDupZ(snapshot.project), + .vector = bytes, + .vector_len = snapshot.vector_dimensions, + }; + written += 1; + if (vector_ids[written - 1] == null or vector.project == null) return false; + } + return written == count; +} + +const SnapshotTokenRow = extern struct { + token_key: i64, + public_id: [*c]u8, + token: [*c]u8, + idf: f64, +}; + +fn snapshotTokenRowCompare(left: ?*const anyopaque, right: ?*const anyopaque) callconv(.c) c_int { + return c.strcmp(cast(SnapshotTokenRow, left).public_id, cast(SnapshotTokenRow, right).public_id); +} + +/// Reads every token vector of the workspace into `out`, which may be the +/// same snapshot, ordered by public token id and numbered from 1. +fn snapshotReadTokenVectors(repo: *Repository, snapshot: *const Snapshot, out: *Snapshot) bool { + var collection: [ID_MAX + 128]u8 = undefined; + if (c.cbm_zova_workspace_token_vector_collection_name(repo.workspaceId(), snapshot.model_fingerprint, snapshot.vector_dimensions, &collection, collection.len) != 0) return false; + const token_count = snapshotVectorCount(repo, @ptrCast(&collection), snapshot.vector_dimensions, snapshot.integrity.token_vectors) orelse return false; + const count: usize = @intCast(token_count); + out.token_vectors = snapshotAlloc(c.CBMDumpTokenVec, count); + out.token_vector_ids = snapshotAlloc([*c]u8, count); + out.token_vector_count = token_count; + if (count != 0 and (out.token_vectors == null or out.token_vector_ids == null)) return false; + const rows_ptr = snapshotAlloc(SnapshotTokenRow, count); + if (count != 0 and rows_ptr == null) return false; + const rows = many(rows_ptr, count); + defer { + for (rows) |*row| { + c.free(row.public_id); + c.free(row.token); + } + c.free(rows_ptr); + } + { + const statement = repoPrepare(repo.db, "SELECT token_key,token,idf FROM cbm_token_vector_metadata_v1 " ++ + "WHERE workspace_key=?1 ORDER BY token_key") orelse return false; + defer repoFinalize(statement); + if (!repoBindI64(statement, 1, repo.workspace_key)) return false; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + for (rows) |*row| { + if (!repoStep(statement, &step) or step != c.ZOVA_STEP_ROW) return false; + row.token_key = repoColumnI64(statement, 0); + row.token = repoColumnText(statement, 1); + row.idf = repoColumnDouble(statement, 2); + var public_id: [ID_MAX + 64]u8 = undefined; + if (row.token_key <= 0 or row.token == null or + c.cbm_zova_workspace_token_id_v1(repo.workspaceId(), row.token, &public_id, public_id.len) != 0) return false; + row.public_id = repoDupZ(&public_id); + if (row.public_id == null) return false; + } + // The metadata table must hold exactly as many tokens as the collection. + if (!repoStep(statement, &step) or step != c.ZOVA_STEP_DONE) return false; + } + if (count > 1) c.qsort(rows.ptr, count, @sizeOf(SnapshotTokenRow), snapshotTokenRowCompare); + for (rows, many(out.token_vectors, count), many(out.token_vector_ids, count), 1..) |*row, *vector, *vector_id, id| { + const bytes = snapshotVectorBytes(repo, @ptrCast(&collection), row.token_key, snapshot.vector_dimensions); + if (bytes == null) return false; + vector_id.* = row.public_id; + row.public_id = null; + vector.* = .{ + .id = @intCast(id), + .project = repoDupZ(snapshot.project), + .token = row.token, + .vector = bytes, + .vector_len = snapshot.vector_dimensions, + .idf = @floatCast(row.idf), + }; + row.token = null; + if (vector.project == null) return false; + } + return true; +} + +fn snapshotReadHashesSummary(repo: *Repository, snapshot: *Snapshot) bool { + const phase_started = repoNow(); + const count = snapshotCount(repo, "SELECT count(*) FROM cbm_file_hashes_v1 h JOIN cbm_files_v1 f " ++ + "ON f.file_key=h.file_key WHERE f.workspace_key=?1"); + if (count < 0) return false; + snapshot.file_hash_count = count; + snapshot.file_hashes = snapshotAlloc(c.cbm_zova_file_hash_input_t, @intCast(count)); + if (count != 0 and snapshot.file_hashes == null) return false; + { + const statement = repoPrepare(repo.db, "SELECT f.file_path,h.content_hash,h.mtime_ns,h.size_bytes FROM cbm_file_hashes_v1 h " ++ + "JOIN cbm_files_v1 f ON f.file_key=h.file_key " ++ + "WHERE f.workspace_key=?1 ORDER BY f.file_path") orelse return false; + defer repoFinalize(statement); + if (!repoBindI64(statement, 1, repo.workspace_key)) return false; + for (many(snapshot.file_hashes, count)) |*hash| { + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoStep(statement, &step) or step != c.ZOVA_STEP_ROW) return false; + hash.file_path = repoColumnText(statement, 0); + hash.content_hash = repoColumnText(statement, 1); + hash.mtime_ns = repoColumnI64(statement, 2); + hash.size_bytes = repoColumnI64(statement, 3); + if (hash.file_path == null or hash.content_hash == null) return false; + } + } + { + const statement = repoPrepare(repo.db, "SELECT summary,source_hash,created_at,updated_at FROM cbm_project_summaries_v2 " ++ + "WHERE workspace_key=?1") orelse return false; + defer repoFinalize(statement); + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoBindI64(statement, 1, repo.workspace_key) or !repoStep(statement, &step)) return false; + if (step == c.ZOVA_STEP_ROW) { + const summary = &snapshot.project_summary; + summary.present = true; + summary.summary = repoColumnText(statement, 0); + summary.source_hash = repoColumnText(statement, 1); + summary.created_at = repoColumnText(statement, 2); + summary.updated_at = repoColumnText(statement, 3); + if (summary.summary == null or summary.source_hash == null or + summary.created_at == null or summary.updated_at == null) return false; + } + } + snapshot.metrics.hashes_summary_ms = repoElapsedMs(phase_started); + snapshot.metrics.file_hash_rows = @intCast(count); + snapshot.metrics.base_phase_mask |= PHASE_HASHES_SUMMARY; + return true; +} + +/// Reads the ready active generation of the repository's workspace into the +/// snapshot and the workspace key into `repo`. +fn snapshotReadHeader(repo: *Repository, snapshot: *Snapshot) bool { + const statement = repoPrepare(repo.db, "SELECT r.workspace_key,r.canonical_root,p.root_path,p.project,p.indexed_at," ++ + "s.model_fingerprint,s.vector_dimensions,r.active_generation " ++ + "FROM cbm_workspace_registry r " ++ + "JOIN cbm_projects_v1 p ON p.workspace_key=r.workspace_key " ++ + "JOIN cbm_workspace_index_state_v1 s ON s.workspace_key=r.workspace_key " ++ + "JOIN cbm_database_generation_v1 g ON g.workspace_key=r.workspace_key " ++ + "AND g.generation=r.active_generation AND g.state='ready' " ++ + "WHERE r.workspace_id=?1 AND s.generation=r.active_generation") orelse return false; + defer repoFinalize(statement); + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoBindText(statement, 1, repo.workspaceId()) or + !repoStep(statement, &step) or step != c.ZOVA_STEP_ROW) return false; + repo.workspace_key = repoColumnI64(statement, 0); + snapshot.root_path = repoColumnText(statement, 1); + const project_root = repoColumnText(statement, 2); + defer c.free(project_root); + snapshot.project = repoColumnText(statement, 3); + snapshot.indexed_at = repoColumnText(statement, 4); + snapshot.model_fingerprint = repoColumnText(statement, 5); + snapshot.vector_dimensions = repoColumnInt(statement, 6); + snapshot.generation = repoColumnI64(statement, 7); + if (snapshot.root_path == null or snapshot.project == null or snapshot.indexed_at == null or + snapshot.model_fingerprint == null or snapshot.vector_dimensions <= 0 or + snapshot.generation <= 0) return false; + var expected: [ID_MAX]u8 = undefined; + if (project_root == null or c.strcmp(project_root, snapshot.root_path) != 0 or + c.cbm_zova_workspace_id_for_root(snapshot.root_path, &expected, expected.len) != 0 or + c.strcmp(&expected, repo.workspaceId()) != 0) return false; + return repoStep(statement, &step) and step == c.ZOVA_STEP_DONE; +} + +fn snapshotCopyDigest(statement: ?*Stmt, column: c_int, out: *Digest) bool { + const digest = repoColumnText(statement, column); + defer c.free(digest); + if (digest == null or c.strlen(digest) != out.len - 1) return false; + @memcpy(out, digest[0..out.len]); + return true; +} + +fn snapshotReadIntegrity(repo: *Repository, snapshot: *Snapshot) bool { + const statement = repoPrepare(repo.db, "SELECT graph_nodes,graph_edges,metadata_nodes,metadata_edges," ++ + "metadata_topology_edges,fts_rows,node_vector_rows,token_vector_rows," ++ + "node_vectors,token_vectors,metadata_sha256,fts_sha256,topology_sha256," ++ + "node_vector_sha256,token_vector_sha256 FROM cbm_generation_integrity_v2 " ++ + "WHERE workspace_key=?1 AND generation=?2") orelse return false; + defer repoFinalize(statement); + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!repoBindI64(statement, 1, repo.workspace_key) or !repoBindI64(statement, 2, snapshot.generation) or + !repoStep(statement, &step) or step != c.ZOVA_STEP_ROW) return false; + const integrity = &snapshot.integrity; + _ = interop.bprint(&integrity.workspace_id, "{s}", .{span(repo.workspaceId())}); + integrity.generation = snapshot.generation; + // In the order the statement selects them. + const counters = [_]*u64{ + &integrity.graph_nodes, &integrity.graph_edges, + &integrity.metadata_nodes, &integrity.metadata_edges, + &integrity.metadata_topology_edges, &integrity.fts_rows, + &integrity.node_vector_rows, &integrity.token_vector_rows, + &integrity.node_vectors, &integrity.token_vectors, + }; + for (counters, 0..) |counter, column| counter.* = @bitCast(repoColumnI64(statement, @intCast(column))); + const digests = [_]*Digest{ + &integrity.metadata_sha256, &integrity.fts_sha256, &integrity.topology_sha256, + &integrity.node_vector_sha256, &integrity.token_vector_sha256, + }; + for (digests, counters.len..) |digest, column| { + if (!snapshotCopyDigest(statement, @intCast(column), digest)) return false; + } + return repoStep(statement, &step) and step == c.ZOVA_STEP_DONE; +} + +/// Whether the graph and both vector collections hold the counts the +/// integrity row records. +fn snapshotVerifyNative(repo: *Repository, snapshot: *const Snapshot) bool { + var graph_name: [ID_MAX + 32]u8 = undefined; + var node_collection: [ID_MAX + 128]u8 = undefined; + var token_collection: [ID_MAX + 128]u8 = undefined; + if (c.cbm_zova_workspace_graph_name(repo.workspaceId(), &graph_name, graph_name.len) != 0 or + c.cbm_zova_workspace_node_vector_collection_name(repo.workspaceId(), snapshot.model_fingerprint, snapshot.vector_dimensions, &node_collection, node_collection.len) != 0 or + c.cbm_zova_workspace_token_vector_collection_name(repo.workspaceId(), snapshot.model_fingerprint, snapshot.vector_dimensions, &token_collection, token_collection.len) != 0) return false; + var graph = std.mem.zeroes(c.zova_graph_info); + defer c.zova_graph_info_free(&graph); + var nodes = std.mem.zeroes(VectorInfo); + defer c.zova_vector_collection_info_free(&nodes); + var tokens = std.mem.zeroes(VectorInfo); + defer c.zova_vector_collection_info_free(&tokens); + return c.zova_graph_info_get(&c.zova_graph_info_get_request{ .db = repo.db, .name = &graph_name, .out_info = &graph }) == c.ZOVA_OK and + c.zova_vector_collection_info_get(&c.zova_vector_collection_info_get_request{ .db = repo.db, .name = &node_collection, .out_info = &nodes }) == c.ZOVA_OK and + c.zova_vector_collection_info_get(&c.zova_vector_collection_info_get_request{ .db = repo.db, .name = &token_collection, .out_info = &tokens }) == c.ZOVA_OK and + graph.node_count == snapshot.integrity.graph_nodes and + graph.edge_count == snapshot.integrity.graph_edges and + nodes.vector_count == snapshot.integrity.node_vectors and + tokens.vector_count == snapshot.integrity.token_vectors; +} + +/// Reads the snapshot inside the open read transaction. `phase` names the +/// base phase reached, for the failure message. +fn snapshotExportRead(repo: *Repository, snapshot: *Snapshot, components: Components, phase: *Str) bool { + phase.* = "header"; + var phase_started = repoNow(); + if (!snapshotReadHeader(repo, snapshot)) return false; + snapshot.metrics.header_ms = repoElapsedMs(phase_started); + snapshot.metrics.base_phase_mask |= PHASE_HEADER; + + phase.* = "integrity"; + phase_started = repoNow(); + if (!snapshotReadIntegrity(repo, snapshot)) return false; + snapshot.metrics.integrity_ms = repoElapsedMs(phase_started); + snapshot.metrics.base_phase_mask |= PHASE_INTEGRITY; + + phase.* = "nodes"; + var node_keys: [*c]SnapshotNodeKeyRef = null; + defer c.free(node_keys); + snapshotReadNodes(repo, snapshot, &node_keys) catch return false; + phase.* = "edges"; + snapshotReadEdges(repo, snapshot, many(node_keys, snapshot.node_count)) catch return false; + phase.* = "hashes_summary"; + if (!snapshotReadHashesSummary(repo, snapshot)) return false; + + const optional_started = repoNow(); + if (components & COMPONENT_TOPOLOGY != 0 and !snapshotBuildTopology(snapshot, snapshot)) return false; + if (components & COMPONENT_NODE_VECTORS != 0 and !snapshotReadNodeVectors(repo, snapshot, snapshot)) return false; + if (components & COMPONENT_TOKEN_VECTORS != 0 and !snapshotReadTokenVectors(repo, snapshot, snapshot)) return false; + snapshot.metrics.optional_ms = if (components == COMPONENT_NONE) 0.0 else repoElapsedMs(optional_started); + const integrity = &snapshot.integrity; + if (snapshot.node_count != narrowInt(integrity.metadata_nodes) or + snapshot.edge_count != narrowInt(integrity.metadata_edges)) return false; + if (components & COMPONENT_NODE_VECTORS != 0 and + snapshot.node_vector_count != narrowInt(integrity.node_vectors)) return false; + if (components & COMPONENT_TOKEN_VECTORS != 0 and + snapshot.token_vector_count != narrowInt(integrity.token_vectors)) return false; + if (components == COMPONENT_ALL and !snapshotVerifyNative(repo, snapshot)) return false; + snapshot.hydrated_components = components; + snapshot.metrics.hydrated_components = components; + snapshot.metrics.topology_rows = @intCast(snapshot.topology_edge_count); + snapshot.metrics.node_vector_rows = @intCast(snapshot.node_vector_count); + snapshot.metrics.token_vector_rows = @intCast(snapshot.token_vector_count); + return true; +} + +fn repositoryExportSnapshot(maybe_path: ?Str, maybe_workspace_id: ?Str, maybe_out: ?*Snapshot, components: Components) c_int { + const out = maybe_out orelse return -1; + out.* = std.mem.zeroes(Snapshot); + const path = nonEmpty(maybe_path) orelse return -1; + if (c.cbm_zova_workspace_id_validate(maybe_workspace_id) != 0) return -1; + const workspace_id = span(maybe_workspace_id.?); + var snapshot = std.mem.zeroes(Snapshot); + var repo = Repository{}; + _ = interop.bprint(&repo.workspace_id, "{s}", .{workspace_id}); + _ = interop.bprint(&snapshot.workspace_id, "{s}", .{workspace_id}); + const base_started = repoNow(); + var phase: Str = "open"; + repo.db = repoOpenReadOnly(path); + snapshot.metrics.open_ms = repoElapsedMs(base_started); + var ok = repo.db != null; + if (ok) snapshot.metrics.base_phase_mask |= PHASE_OPEN; + var transaction_started = false; + if (ok) { + ok = c.cbm_zova_register_sql_functions(repo.db) == 0 and repoExec(repo.db, "BEGIN"); + transaction_started = ok; + } + if (ok) ok = snapshotExportRead(&repo, &snapshot, components, &phase); + const close_started = repoNow(); + if (transaction_started) { + const finished = repoExec(repo.db, if (ok) "COMMIT" else "ROLLBACK"); + if (ok and !finished) ok = false; + } + if (repo.db) |db| _ = c.zova_database_close(db); + snapshot.metrics.close_ms = repoElapsedMs(close_started); + if (ok) snapshot.metrics.base_phase_mask |= PHASE_CLOSE; + snapshot.metrics.base_ms = repoElapsedMs(base_started); + if (!ok) { + _ = c.fprintf(interop.stderr(), "zova snapshot export failed phase=%s\n", phase); + cbm_zova_workspace_snapshot_free(&snapshot); + return -1; + } + out.* = snapshot; + return 0; +} + +/// What the snapshot exports do in a build without Zova. +fn snapshotUnavailable(maybe_out: ?*Snapshot) c_int { + if (maybe_out) |out| out.* = std.mem.zeroes(Snapshot); + return -1; +} + +pub export fn cbm_zova_repository_export_snapshot(path: ?Str, workspace_id: ?Str, out: ?*Snapshot) c_int { + if (!with_zova) return snapshotUnavailable(out); + return repositoryExportSnapshot(path, workspace_id, out, COMPONENT_ALL); +} + +pub export fn cbm_zova_repository_export_incremental_snapshot(path: ?Str, workspace_id: ?Str, out: ?*Snapshot) c_int { + if (!with_zova) return snapshotUnavailable(out); + return repositoryExportSnapshot(path, workspace_id, out, COMPONENT_NONE); +} + +const SnapshotGraphNodeOrder = extern struct { + source_ordinal: u64, + index: c_int, +}; + +fn snapshotGraphNodeOrderCompare(left_ptr: ?*const anyopaque, right_ptr: ?*const anyopaque) callconv(.c) c_int { + const left = cast(SnapshotGraphNodeOrder, left_ptr); + const right = cast(SnapshotGraphNodeOrder, right_ptr); + if (left.source_ordinal != right.source_ordinal) return if (left.source_ordinal < right.source_ordinal) -1 else 1; + return @as(c_int, @intFromBool(left.index > right.index)) - @intFromBool(left.index < right.index); +} + +/// Loads the snapshot's nodes, in source order, and its edges into `graph`. +fn snapshotLoadGraph(snapshot: *const Snapshot, graph: ?*c.cbm_gbuf_t) bool { + if (c.cbm_gbuf_reserve(graph, snapshot.node_count, snapshot.edge_count) != 0) return false; + const node_count: usize = @intCast(snapshot.node_count); + // Graph buffer ids by snapshot node id, which starts at 1. + const ids_ptr = snapshotAlloc(i64, if (node_count != 0) node_count + 1 else 0); + defer c.free(ids_ptr); + const order_ptr = snapshotAlloc(SnapshotGraphNodeOrder, node_count); + defer c.free(order_ptr); + if (node_count != 0 and (ids_ptr == null or order_ptr == null or snapshot.node_source_ordinals == null)) return false; + const ids = many(ids_ptr, node_count + 1); + const order = many(order_ptr, node_count); + for (order, many(snapshot.node_source_ordinals, node_count), 0..) |*entry, source_ordinal, i| { + entry.* = .{ .source_ordinal = source_ordinal, .index = @intCast(i) }; + } + if (node_count > 1) c.qsort(order.ptr, node_count, @sizeOf(SnapshotGraphNodeOrder), snapshotGraphNodeOrderCompare); + const nodes = many(snapshot.nodes, node_count); + for (order) |entry| { + const node = &nodes[@intCast(entry.index)]; + if (node.id <= 0 or node.id > snapshot.node_count or ids[@intCast(node.id)] != 0) return false; + const id = c.cbm_gbuf_upsert_node(graph, node.label, node.name, node.qualified_name, node.file_path, node.start_line, node.end_line, node.properties); + ids[@intCast(node.id)] = id; + if (id <= 0) return false; + } + for (many(snapshot.edges, snapshot.edge_count)) |*edge| { + const source = if (edge.source_id > 0 and edge.source_id <= snapshot.node_count) ids[@intCast(edge.source_id)] else 0; + const target = if (edge.target_id > 0 and edge.target_id <= snapshot.node_count) ids[@intCast(edge.target_id)] else 0; + if (source <= 0 or target <= 0 or + c.cbm_gbuf_insert_edge(graph, source, target, edge.type, edge.properties) <= 0) return false; + } + return true; +} + +pub export fn cbm_zova_repository_export_incremental_snapshot_to_graph(path: ?Str, workspace_id: ?Str, maybe_out: ?*Snapshot, graph: ?*c.cbm_gbuf_t) c_int { + if (!with_zova) return snapshotUnavailable(maybe_out); + if (graph == null or cbm_zova_repository_export_incremental_snapshot(path, workspace_id, maybe_out) != 0) return -1; + const out = maybe_out.?; + const started = repoNow(); + if (!snapshotLoadGraph(out, graph)) { + cbm_zova_workspace_snapshot_free(out); + return -1; + } + out.metrics.graph_buffer_ms = repoElapsedMs(started); + return 0; +} + +fn snapshotIntegrityMatches(left: *const Snapshot, right: *const Snapshot) bool { + const a = &left.integrity; + const b = &right.integrity; + return left.generation == right.generation and + c.strcmp(&left.workspace_id, &right.workspace_id) == 0 and + a.metadata_nodes == b.metadata_nodes and a.metadata_edges == b.metadata_edges and + a.metadata_topology_edges == b.metadata_topology_edges and + a.node_vectors == b.node_vectors and a.token_vectors == b.token_vectors and + c.strcmp(&a.metadata_sha256, &b.metadata_sha256) == 0 and + c.strcmp(&a.fts_sha256, &b.fts_sha256) == 0 and + c.strcmp(&a.topology_sha256, &b.topology_sha256) == 0 and + c.strcmp(&a.node_vector_sha256, &b.node_vector_sha256) == 0 and + c.strcmp(&a.token_vector_sha256, &b.token_vector_sha256) == 0; +} + +/// Reads the pending vector components into `hydrated` inside the open read +/// transaction, after checking that the database still holds the generation +/// `snapshot` was read from. +fn snapshotHydrateVectors(repo: *Repository, pending: Components, snapshot: *const Snapshot, current: *Snapshot, hydrated: *Snapshot) c_int { + if (!snapshotReadHeader(repo, current) or !snapshotReadIntegrity(repo, current)) return c.CBM_ZOVA_SNAPSHOT_ERROR; + if (!snapshotIntegrityMatches(snapshot, current)) return c.CBM_ZOVA_SNAPSHOT_STALE; + if (pending & COMPONENT_NODE_VECTORS != 0 and !snapshotReadNodeVectors(repo, snapshot, hydrated)) return c.CBM_ZOVA_SNAPSHOT_ERROR; + if (pending & COMPONENT_TOKEN_VECTORS != 0 and !snapshotReadTokenVectors(repo, snapshot, hydrated)) return c.CBM_ZOVA_SNAPSHOT_ERROR; + return c.CBM_ZOVA_SNAPSHOT_OK; +} + +/// Reads the pending components of `snapshot` into `hydrated`. The topology +/// derives from the snapshot itself; only the vectors need the database. +fn snapshotHydrate(path: Str, workspace_id: Str, pending: Components, snapshot: *const Snapshot, hydrated: *Snapshot) c_int { + if (pending & COMPONENT_TOPOLOGY != 0 and !snapshotBuildTopology(snapshot, hydrated)) return c.CBM_ZOVA_SNAPSHOT_ERROR; + if (pending & (COMPONENT_NODE_VECTORS | COMPONENT_TOKEN_VECTORS) == 0) return c.CBM_ZOVA_SNAPSHOT_OK; + var repo = Repository{}; + var current = std.mem.zeroes(Snapshot); + defer cbm_zova_workspace_snapshot_free(¤t); + _ = interop.bprint(&repo.workspace_id, "{s}", .{span(workspace_id)}); + _ = interop.bprint(¤t.workspace_id, "{s}", .{span(workspace_id)}); + const db = repoOpenReadOnly(path) orelse return c.CBM_ZOVA_SNAPSHOT_ERROR; + defer _ = c.zova_database_close(db); + repo.db = db; + if (c.cbm_zova_register_sql_functions(db) != 0 or !repoExec(db, "PRAGMA query_only=ON;BEGIN")) return c.CBM_ZOVA_SNAPSHOT_ERROR; + const rc = snapshotHydrateVectors(&repo, pending, snapshot, ¤t, hydrated); + const finished = repoExec(db, if (rc == c.CBM_ZOVA_SNAPSHOT_OK) "COMMIT" else "ROLLBACK"); + return if (rc == c.CBM_ZOVA_SNAPSHOT_OK and !finished) c.CBM_ZOVA_SNAPSHOT_ERROR else rc; +} + +pub export fn cbm_zova_repository_hydrate_incremental_components(maybe_path: ?Str, maybe_workspace_id: ?Str, expected_generation: i64, requested: Components, maybe_snapshot: ?*Snapshot) c_int { + if (!with_zova) return c.CBM_ZOVA_SNAPSHOT_ERROR; + const path = nonEmpty(maybe_path) orelse return c.CBM_ZOVA_SNAPSHOT_ERROR; + const snapshot = maybe_snapshot orelse return c.CBM_ZOVA_SNAPSHOT_ERROR; + if (c.cbm_zova_workspace_id_validate(maybe_workspace_id) != 0) return c.CBM_ZOVA_SNAPSHOT_ERROR; + const workspace_id = maybe_workspace_id.?; + if (c.strcmp(workspace_id, &snapshot.workspace_id) != 0 or + requested == COMPONENT_NONE or requested & ~COMPONENT_ALL != 0) return c.CBM_ZOVA_SNAPSHOT_ERROR; + if (expected_generation <= 0 or expected_generation != snapshot.generation) return c.CBM_ZOVA_SNAPSHOT_STALE; + const pending = requested & ~snapshot.hydrated_components; + if (pending == COMPONENT_NONE) return c.CBM_ZOVA_SNAPSHOT_OK; + + const started = repoNow(); + var hydrated = std.mem.zeroes(Snapshot); + defer cbm_zova_workspace_snapshot_free(&hydrated); + const rc = snapshotHydrate(path, workspace_id, pending, snapshot, &hydrated); + if (rc != c.CBM_ZOVA_SNAPSHOT_OK) return rc; + + if (pending & COMPONENT_TOPOLOGY != 0) { + snapshot.topology_edges = hydrated.topology_edges; + snapshot.topology_edge_count = hydrated.topology_edge_count; + hydrated.topology_edges = null; + hydrated.topology_edge_count = 0; + } + if (pending & COMPONENT_NODE_VECTORS != 0) { + snapshot.node_vectors = hydrated.node_vectors; + snapshot.node_vector_ids = hydrated.node_vector_ids; + snapshot.node_vector_count = hydrated.node_vector_count; + hydrated.node_vectors = null; + hydrated.node_vector_ids = null; + hydrated.node_vector_count = 0; + } + if (pending & COMPONENT_TOKEN_VECTORS != 0) { + snapshot.token_vectors = hydrated.token_vectors; + snapshot.token_vector_ids = hydrated.token_vector_ids; + snapshot.token_vector_count = hydrated.token_vector_count; + hydrated.token_vectors = null; + hydrated.token_vector_ids = null; + hydrated.token_vector_count = 0; + } + snapshot.hydrated_components |= pending; + snapshot.metrics.hydrated_components = snapshot.hydrated_components; + snapshot.metrics.topology_rows = @intCast(snapshot.topology_edge_count); + snapshot.metrics.node_vector_rows = @intCast(snapshot.node_vector_count); + snapshot.metrics.token_vector_rows = @intCast(snapshot.token_vector_count); + snapshot.metrics.optional_ms += repoElapsedMs(started); + return c.CBM_ZOVA_SNAPSHOT_OK; +} From 0687076fbca36f29104bdc2c6d6e2e0663812669 Mon Sep 17 00:00:00 2001 From: ata-sesli Date: Tue, 10 Nov 2026 23:08:32 +0300 Subject: [PATCH 8/9] refactor(zova): port cbm_zova from C to Zig src/zova/cbm_zova.c (6,125 lines) becomes src/zova/cbm_zova.zig (5,033 lines). cbm_zova.h stays the ABI: every function keeps its name and signature, and src/cbm_zig.zig checks each export against the header. This was the last C file of src/zova; ZOVA_SRCS in Makefile.cbm is now empty. Form differences from the C file: - The C file had two bodies per function, a no-Zova stub and the real one. The Zig file has one export per function that starts with the stub behaviour (`if (!with_zova) ...`). - The seven functions that only a Zova build defines sit in the `zova_only` struct, which is empty without Zova: cbm_zova_database_format_status, cbm_zova_repack_phase_name, cbm_zova_register_sql_functions, cbm_zova_user_database_verify_workspace_schema, cbm_zova_user_database_schema_is_current, cbm_zova_user_database_publish_workspace_tx and cbm_zova_user_database_publish_model_tx. - Internal helpers return bool or an optional instead of 0/-1 plus an out parameter. Functions that record profile phases or write outputs after a failure keep the C control flow (`ok = ok and step()`). - user_database_publish_model_at_generation and cbm_zova_user_database_publish_delta were two copies of the same guard/open/WAL/transaction/timing code. Both now call userDatabasePublish, which takes the transaction body as a tagged union. - publishModelBuild is the model-building prelude that publish_workspace_tx and publish_workspace_at_generation shared. - sessionOpen opens both the vector and the graph session. - The three identical binary searches over file, node and token keys share namedKeyFind. - cbm_zova_user_database_delete_workspace builds its four "DELETE FROM WHERE workspace_key=?1" statements at compile time instead of with snprintf. - Parameters and outputs that no caller used are gone (`model` in the delta node upsert, `workspace_key` and `out_edge_keys` in the delta native put, `db` in the statement reset helpers, `project` in write_nodes), and so are two unused struct types. - All SQL text is unchanged. A script compared every string literal of the two files; the only differences are printf formats written as Zig formats and phase names assembled at compile time. Behavior differences (all on failure paths): - cbm_zova_graph_session_adjacency freed `count + 1` rows when a neighbor query failed, which read one row past the array when it was full. It frees `count` rows. - user_database_write_edges leaked the edge payload buffer when encoding the payload failed. The buffer is freed. - user_delta_delete_native_tx: when a topology delete row was NULL the C code set rc = -1 but still called zova_graph_edge_delete_many on the partly filled array, and that call's status replaced the error. It now fails before the call. Reported, not changed: - cbm_zova_user_database_delete_workspace ignores a failure to prepare or bind the index state lookup. It then treats the workspace as having no vector collections, deletes its rows and leaves the collections behind. The port keeps that. Build wiring: Makefile.cbm (ZOVA_SRCS emptied, cbm_zova.zig added to CBM_ZIG_SRCS), build.zig (C file removed from native_sources), src/cbm_zig.zig (checkExports for the module and for zova_only). Signed-off-by: ata-sesli --- Makefile.cbm | 9 +- build.zig | 1 - src/cbm_zig.zig | 2 + src/zova/cbm_zova.c | 6125 ----------------------------------------- src/zova/cbm_zova.zig | 5033 +++++++++++++++++++++++++++++++++ 5 files changed, 5040 insertions(+), 6130 deletions(-) delete mode 100644 src/zova/cbm_zova.c create mode 100644 src/zova/cbm_zova.zig diff --git a/Makefile.cbm b/Makefile.cbm index 4c01176c..8205b2a7 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -149,10 +149,10 @@ PREPROCESSOR_SRC = $(CBM_DIR)/preprocessor.cpp # SQLite writer -# Optional Zova storage adapter. Compiles to no-op stubs unless -# CBM_WITH_ZOVA=1 is set for the build. -ZOVA_SRCS = \ - src/zova/cbm_zova.c +# Optional Zova storage adapter: implemented in Zig (src/zova/*.zig, part of +# libcbm_zig). Its database functions are no-op stubs unless CBM_WITH_ZOVA=1 +# is set for the build. +ZOVA_SRCS = # MCP server module: implemented in Zig (src/mcp/*.zig, part of libcbm_zig) MCP_SRCS = @@ -482,6 +482,7 @@ CBM_ZIG_SRCS = $(CBM_ZIG_SRC) src/cbm_zig_c.h src/cli_zig/cbm_cli_zig.zig src/cl src/simhash/minhash.zig src/simhash/minhash.h \ src/store/store.zig src/store/store.h \ src/watcher/watcher.zig src/watcher/watcher.h \ + src/zova/cbm_zova.zig \ src/zova/cbm_zova_bridge_stub.zig src/zova/cbm_zova_coordination.zig \ src/zova/cbm_zova_delta.zig src/zova/cbm_zova_edge_payload.zig \ src/zova/cbm_zova_operations.zig \ diff --git a/build.zig b/build.zig index 73aa11d0..c1023226 100644 --- a/build.zig +++ b/build.zig @@ -536,7 +536,6 @@ fn grammarSources(b: *std.Build) []const []const u8 { const native_sources = [_][]const u8{ "src/foundation/hash_table.c", "src/foundation/c_shim.c", - "src/zova/cbm_zova.c", "src/pipeline/fqn.c", "src/pipeline/path_alias.c", "src/pipeline/laravel_routing.c", diff --git a/src/cbm_zig.zig b/src/cbm_zig.zig index 606af9cd..36eeb008 100644 --- a/src/cbm_zig.zig +++ b/src/cbm_zig.zig @@ -64,6 +64,8 @@ comptime { checkExports(@import("simhash/minhash.zig")); checkExports(@import("store/store.zig")); checkExports(@import("watcher/watcher.zig")); + checkExports(@import("zova/cbm_zova.zig")); + checkExports(@import("zova/cbm_zova.zig").zova_only); if (c.CBM_WITH_ZOVA == 0) checkExports(@import("zova/cbm_zova_bridge_stub.zig")); checkExports(@import("zova/cbm_zova_coordination.zig")); checkExports(@import("zova/cbm_zova_delta.zig")); diff --git a/src/zova/cbm_zova.c b/src/zova/cbm_zova.c deleted file mode 100644 index d607969b..00000000 --- a/src/zova/cbm_zova.c +++ /dev/null @@ -1,6125 +0,0 @@ -#include "zova/cbm_zova.h" -#include "zova/cbm_zova_edge_payload.h" -#include "zova/cbm_zova_publish_model.h" -#include "zova/cbm_zova_delta.h" -#include "zova/cbm_zova_writer_gate.h" - -#include "foundation/compat.h" -#include "foundation/compat_fs.h" -#include "foundation/compat_regex.h" -#include "foundation/log.h" -#include "foundation/platform.h" -#include "foundation/sha256.h" - -#include -#include -#include -#include -#include -#include - -#define CBM_NODE_FTS_INSERT_TRIGGER_SQL \ - "CREATE TRIGGER IF NOT EXISTS cbm_nodes_v1_fts_ai AFTER INSERT ON cbm_nodes_v1 BEGIN " \ - "INSERT INTO cbm_nodes_fts_v1(rowid,name,qualified_name,file_path,label) " \ - "SELECT NEW.zova_node_key,cbm_camel_split(NEW.name),NEW.qualified_name,f.file_path,NEW.label " \ - "FROM cbm_files_v1 f WHERE f.file_key=NEW.file_key; END;" - -#ifndef CBM_WITH_ZOVA -#define CBM_WITH_ZOVA 0 -#endif - -enum { - ZV_PATH_MAX = 1024, - ZV_BATCH = 512, - ZV_ID_MAX = 32, - ZV_SQL_MAX = 512, -}; - -static cbm_zova_publish_test_metrics_t user_publish_test_metrics = {0}; -#if CBM_WITH_ZOVA -static _Thread_local bool user_publish_delta_active = false; -#endif - -static void user_publish_metric_row(cbm_zova_statement_phase_metrics_t *metrics, - uint64_t bind_i64_calls, - uint64_t bind_text_calls, - uint64_t bind_double_calls, - bool cleared_bindings) { - if (!metrics) return; - metrics->rows++; - metrics->bind_i64_calls += bind_i64_calls; - metrics->bind_text_calls += bind_text_calls; - metrics->bind_double_calls += bind_double_calls; - metrics->step_calls++; - metrics->reset_calls++; - if (cleared_bindings) metrics->clear_bindings_calls++; -} - -void cbm_zova_publish_test_metrics_reset(void) { - memset(&user_publish_test_metrics, 0, sizeof(user_publish_test_metrics)); -} - -void cbm_zova_publish_test_metrics_get(cbm_zova_publish_test_metrics_t *out_metrics) { - if (out_metrics) *out_metrics = user_publish_test_metrics; -} - -bool cbm_zova_build_enabled(void) { - return CBM_WITH_ZOVA != 0; -} - -static int workspace_normalize_root(const char *root_path, char out[ZV_PATH_MAX]) { - if (!root_path || !root_path[0] || - snprintf(out, ZV_PATH_MAX, "%s", root_path) >= ZV_PATH_MAX) { - return -1; - } - cbm_normalize_path_sep(out); - size_t len = strlen(out); - while (len > 1 && out[len - 1] == '/') out[--len] = '\0'; - return 0; -} - -static int workspace_id_from_root(const char *root, char *out, size_t out_size) { - char digest[CBM_SHA256_HEX_LEN + 1]; - cbm_sha256_hex(root, strlen(root), digest); - return snprintf(out, out_size, "w1_%.32s", digest) < (int)out_size ? 0 : -1; -} - -int cbm_zova_workspace_id_for_root(const char *root_path, char *out_workspace_id, - size_t out_workspace_id_size) { - char root[ZV_PATH_MAX]; - if (!out_workspace_id || out_workspace_id_size == 0 || - workspace_normalize_root(root_path, root) != 0) - return -1; - return workspace_id_from_root(root, out_workspace_id, out_workspace_id_size); -} - -int cbm_zova_workspace_registry_path(char *out, size_t out_size) { - const char *cache_dir = cbm_resolve_cache_dir(); - if (!cache_dir || !out || out_size == 0) { - return -1; - } - return snprintf(out, out_size, "%s/cbm.zova", cache_dir) < (int)out_size ? 0 : -1; -} - -static bool workspace_name_component_valid(const char *value) { - if (!value || !value[0]) { - return false; - } - for (const unsigned char *p = (const unsigned char *)value; *p; p++) { - if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || - (*p >= '0' && *p <= '9') || *p == '_')) { - return false; - } - } - return true; -} - -int cbm_zova_workspace_id_validate(const char *workspace_id) { - return workspace_name_component_valid(workspace_id) ? 0 : -1; -} - -int cbm_zova_workspace_token_id_v1(const char *workspace_id, const char *token, - char *out, size_t out_size) { - if (!workspace_name_component_valid(workspace_id) || !token || !out || out_size == 0) { - return -1; - } - cbm_sha256_ctx hash; - uint8_t digest[CBM_SHA256_DIGEST_LEN]; - static const char digits[] = "0123456789abcdef"; - char hex[33]; - cbm_sha256_init(&hash); - cbm_sha256_update(&hash, token, strlen(token)); - cbm_sha256_update(&hash, "\0", 1); - cbm_sha256_final(&hash, digest); - for (size_t i = 0; i < 16; i++) { - hex[i * 2] = digits[digest[i] >> 4]; - hex[i * 2 + 1] = digits[digest[i] & 0x0f]; - } - hex[32] = '\0'; - return snprintf(out, out_size, "t:v1:%s:%s", workspace_id, hex) < (int)out_size ? 0 : -1; -} - -int cbm_zova_workspace_graph_name(const char *workspace_id, char *out, size_t out_size) { - if (!workspace_name_component_valid(workspace_id) || !out || out_size == 0) { - return -1; - } - return snprintf(out, out_size, "cbm_graph_%s", workspace_id) < (int)out_size ? 0 : -1; -} - -int cbm_zova_workspace_node_vector_collection_name(const char *workspace_id, - const char *model_fingerprint, int dimensions, - char *out, size_t out_size) { - if (!workspace_name_component_valid(workspace_id) || - !workspace_name_component_valid(model_fingerprint) || dimensions <= 0 || !out || - out_size == 0) { - return -1; - } - return snprintf(out, out_size, "cbm_nodes_i8_%s_%s_d%d", workspace_id, model_fingerprint, - dimensions) < (int)out_size - ? 0 - : -1; -} - -int cbm_zova_workspace_token_vector_collection_name(const char *workspace_id, - const char *model_fingerprint, int dimensions, - char *out, size_t out_size) { - if (!workspace_name_component_valid(workspace_id) || - !workspace_name_component_valid(model_fingerprint) || dimensions <= 0 || !out || - out_size == 0) { - return -1; - } - return snprintf(out, out_size, "cbm_tokens_i8_%s_%s_d%d", workspace_id, model_fingerprint, - dimensions) < (int)out_size - ? 0 - : -1; -} - -int cbm_zova_workspace_node_id_v1(const char *workspace_id, const char *node_kind, - const char *relative_path, const char *qualified_name, - const char *semantic_discriminator, char *out, size_t out_size) { - if (!workspace_name_component_valid(workspace_id) || !node_kind || !relative_path || - !qualified_name || !semantic_discriminator || !out || out_size == 0) { - return -1; - } - const char *parts[] = {node_kind, relative_path, qualified_name, semantic_discriminator}; - cbm_sha256_ctx hash; - uint8_t digest[CBM_SHA256_DIGEST_LEN]; - char hex[33]; - static const char digits[] = "0123456789abcdef"; - cbm_sha256_init(&hash); - for (size_t i = 0; i < sizeof(parts) / sizeof(parts[0]); i++) { - cbm_sha256_update(&hash, parts[i], strlen(parts[i])); - cbm_sha256_update(&hash, "\0", 1); - } - cbm_sha256_final(&hash, digest); - for (size_t i = 0; i < 16; i++) { - hex[i * 2] = digits[digest[i] >> 4]; - hex[i * 2 + 1] = digits[digest[i] & 0x0f]; - } - hex[32] = '\0'; - return snprintf(out, out_size, "n:v1:%s:%s", workspace_id, hex) < (int)out_size ? 0 : -1; -} - -int cbm_zova_workspace_node_id_v2(const char *workspace_id, const char *node_kind, - const char *relative_path, const char *qualified_name, - const char *semantic_discriminator, char *out, size_t out_size) { - if (!workspace_name_component_valid(workspace_id) || !node_kind || !node_kind[0] || - !relative_path || !qualified_name || !semantic_discriminator || - !semantic_discriminator[0] || !out || out_size == 0) { - return -1; - } - const char *parts[] = {node_kind, relative_path, qualified_name, semantic_discriminator}; - cbm_sha256_ctx hash; - uint8_t digest[CBM_SHA256_DIGEST_LEN]; - char hex[33]; - static const char digits[] = "0123456789abcdef"; - cbm_sha256_init(&hash); - for (size_t i = 0; i < sizeof(parts) / sizeof(parts[0]); i++) { - cbm_sha256_update(&hash, parts[i], strlen(parts[i])); - cbm_sha256_update(&hash, "\0", 1); - } - cbm_sha256_final(&hash, digest); - for (size_t i = 0; i < 16; i++) { - hex[i * 2] = digits[digest[i] >> 4]; - hex[i * 2 + 1] = digits[digest[i] & 0x0f]; - } - hex[32] = '\0'; - return snprintf(out, out_size, "n:v2:%s:%s", workspace_id, hex) < (int)out_size ? 0 : -1; -} - -void cbm_zova_vector_hits_free(cbm_zova_vector_hit_t *items, int count) { - if (!items) return; - for (int i = 0; i < count; i++) free(items[i].vector_id); - free(items); -} - -void cbm_zova_graph_visits_free(cbm_zova_graph_visit_t *visits, int count) { - if (!visits) { - return; - } - for (int i = 0; i < count; i++) { - free(visits[i].node_id); - free(visits[i].name); - free(visits[i].qualified_name); - free(visits[i].file_path); - free(visits[i].label); - free(visits[i].properties); - } - free(visits); -} - -#if !CBM_WITH_ZOVA - -int cbm_zova_workspace_generation_digest_input( - const char *workspace_id, const cbm_zova_workspace_generation_input_t *input, - cbm_zova_workspace_generation_result_t *out_result) { - (void)workspace_id; - (void)input; - if (out_result) memset(out_result, 0, sizeof(*out_result)); - return -1; -} - -int cbm_zova_probe_sql_capabilities(const char *zova_path, - cbm_zova_sql_capabilities_t *out_capabilities) { - (void)zova_path; - if (out_capabilities) { - memset(out_capabilities, 0, sizeof(*out_capabilities)); - } - return -1; -} - -int cbm_zova_user_database_init(const char *zova_path) { - (void)zova_path; - return -1; -} - -int cbm_zova_user_database_generation_begin(const char *zova_path, const char *root_path, - int64_t generation, char *out_workspace_id, - size_t out_workspace_id_size) { - (void)zova_path; - (void)root_path; - (void)generation; - (void)out_workspace_id; - (void)out_workspace_id_size; - return -1; -} - -int cbm_zova_user_database_generation_finish(const char *zova_path, const char *workspace_id, - int64_t generation, bool ready) { - (void)zova_path; - (void)workspace_id; - (void)generation; - (void)ready; - return -1; -} - -int cbm_zova_user_database_import_workspace( - const char *zova_path, const char *root_path, const char *project, int64_t generation, - const CBMDumpNode *nodes, int node_count, const CBMDumpEdge *edges, int edge_count) { - (void)zova_path; - (void)root_path; - (void)project; - (void)generation; - (void)nodes; - (void)node_count; - (void)edges; - (void)edge_count; - return -1; -} - -int cbm_zova_user_database_publish_workspace( - const char *zova_path, const cbm_zova_workspace_generation_input_t *input, - cbm_zova_workspace_generation_result_t *out_result) { - (void)zova_path; - (void)input; - if (out_result) memset(out_result, 0, sizeof(*out_result)); - return -1; -} - -int cbm_zova_user_database_publish_model( - const char *zova_path, const cbm_zova_publish_model_t *model, - cbm_zova_workspace_generation_result_t *out_result) { - (void)zova_path; - (void)model; - if (out_result) memset(out_result, 0, sizeof(*out_result)); - return -1; -} - -int cbm_zova_user_database_publish_prepared_view( - const char *zova_path, const cbm_zova_prepared_view_t *view, - cbm_zova_workspace_generation_result_t *out_result) { - (void)zova_path; - (void)view; - if (out_result) memset(out_result, 0, sizeof(*out_result)); - return -1; -} - -int cbm_zova_user_database_publish_delta( - const char *zova_path, const cbm_zova_publish_model_t *after, - const cbm_zova_workspace_delta_t *delta, - cbm_zova_workspace_generation_result_t *out_result) { - (void)zova_path; - (void)after; - (void)delta; - if (out_result) memset(out_result, 0, sizeof(*out_result)); - return -1; -} - -int cbm_zova_user_database_publish_workspace_at_generation( - const char *zova_path, const cbm_zova_workspace_generation_input_t *input, - int64_t requested_generation, cbm_zova_workspace_generation_result_t *out_result) { - (void)zova_path; - (void)input; - (void)requested_generation; - if (out_result) memset(out_result, 0, sizeof(*out_result)); - return -1; -} - -int cbm_zova_user_database_delete_workspace(const char *zova_path, - const char *workspace_id) { - (void)zova_path; - (void)workspace_id; - return -1; -} - -int cbm_zova_delete_workspace_graph(const char *zova_path, const char *workspace_id) { - (void)zova_path; - (void)workspace_id; - return -1; -} - -int cbm_zova_workspace_get_or_create_at(const char *registry_path, const char *root_path, - char *out_workspace_id, size_t out_workspace_id_size) { - (void)registry_path; - (void)root_path; - (void)out_workspace_id; - (void)out_workspace_id_size; - return -1; -} - -int cbm_zova_workspace_lookup_at(const char *registry_path, const char *root_path, - char *out_workspace_id, size_t out_workspace_id_size) { - (void)registry_path; - (void)root_path; - if (out_workspace_id && out_workspace_id_size > 0) { - out_workspace_id[0] = '\0'; - } - return -1; -} - -int cbm_zova_workspace_generation_begin_at(const char *registry_path, const char *workspace_id, - int64_t generation) { - (void)registry_path; - (void)workspace_id; - (void)generation; - return -1; -} - -int cbm_zova_workspace_generation_finish_at(const char *registry_path, const char *workspace_id, - int64_t generation, bool ready) { - (void)registry_path; - (void)workspace_id; - (void)generation; - (void)ready; - return -1; -} - -int cbm_zova_workspace_active_generation_at(const char *registry_path, const char *workspace_id, - int64_t *out_generation) { - (void)registry_path; - (void)workspace_id; - (void)out_generation; - return -1; -} - -cbm_zova_vector_session_t *cbm_zova_vector_session_open(const char *zova_path) { - (void)zova_path; - return NULL; -} - -int cbm_zova_vector_session_search_collection_i8( - cbm_zova_vector_session_t *session, const char *collection, const int8_t *query_values, - int query_count, int vector_dim, int prefilter_limit, int limit, - cbm_zova_vector_hit_t **out, int *out_count) { - (void)session; (void)collection; (void)query_values; (void)query_count; - (void)vector_dim; (void)prefilter_limit; (void)limit; - if (out) *out = NULL; - if (out_count) *out_count = 0; - return -1; -} - -int cbm_zova_vector_session_get_workspace_token_i8( - cbm_zova_vector_session_t *session, const char *workspace_id, - const char *model_fingerprint, int vector_dim, const char *token, - int8_t *out_values, size_t out_len, bool *out_found) { - (void)session; (void)workspace_id; (void)model_fingerprint; (void)vector_dim; - (void)token; (void)out_values; (void)out_len; - if (out_found) *out_found = false; - return -1; -} - -cbm_zova_graph_session_t *cbm_zova_graph_session_open(const char *zova_path) { - (void)zova_path; - return NULL; -} - -int cbm_zova_graph_session_generation(const cbm_zova_graph_session_t *session, - int64_t *out_generation) { - (void)session; - if (out_generation) { - *out_generation = 0; - } - return -1; -} - -void cbm_zova_graph_adjacency_free(cbm_zova_graph_adjacency_t *items, int count) { - if (!items) return; - for (int i = 0; i < count; ++i) { - free(items[i].source_node_id); - free(items[i].target_node_id); - free(items[i].edge_type); - free(items[i].properties); - } - free(items); -} -int cbm_zova_graph_session_adjacency( - cbm_zova_graph_session_t *session, const char *workspace_id, const char *graph_name, - const char *node_id, const char *direction, const char *const *edge_types, - int edge_type_count, int max_results, cbm_zova_graph_adjacency_t **out, int *out_count) { - (void)session;(void)workspace_id;(void)graph_name;(void)node_id;(void)direction; - (void)edge_types;(void)edge_type_count;(void)max_results; - if(out)*out=NULL;if(out_count)*out_count=0;return -1; -} - -int cbm_zova_graph_session_degree( - cbm_zova_graph_session_t *session, const char *workspace_id, const char *graph_name, - const char *node_id, const char *direction, const char *const *edge_types, - int edge_type_count, int *out_degree) { - (void)session;(void)workspace_id;(void)graph_name;(void)node_id;(void)direction; - (void)edge_types;(void)edge_type_count;if(out_degree)*out_degree=0;return -1; -} -int cbm_zova_graph_session_adjacency_prepare_count( - const cbm_zova_graph_session_t *session) {(void)session;return 0;} - -void cbm_zova_graph_session_close(cbm_zova_graph_session_t *session) { - (void)session; -} - -int cbm_zova_graph_session_walk_calls( - cbm_zova_graph_session_t *session, const char *workspace_id, const char *graph_name, - const char *start_node_id, const char *direction, int max_depth, int max_results, - cbm_zova_graph_visit_t **out_visits, int *out_count, cbm_zova_graph_metrics_t *out_metrics) { - (void)session; - (void)workspace_id; - (void)graph_name; - (void)start_node_id; - (void)direction; - (void)max_depth; - (void)max_results; - if (out_visits) { - *out_visits = NULL; - } - if (out_count) { - *out_count = 0; - } - if (out_metrics) { - memset(out_metrics, 0, sizeof(*out_metrics)); - out_metrics->fallback = true; - } - return -1; -} - -void cbm_zova_vector_session_close(cbm_zova_vector_session_t *session) { - (void)session; -} - -int cbm_zova_vector_session_generation(const cbm_zova_vector_session_t *session, - int64_t *out_generation) { - (void)session; - if (out_generation) { - *out_generation = 0; - } - return -1; -} - -int cbm_zova_vector_session_has_workspace(const cbm_zova_vector_session_t *session, - const char *workspace_id, bool *out_present) { - (void)session; - (void)workspace_id; - if (out_present) { - *out_present = false; - } - return -1; -} - -#else - -#include "zova.h" - -#include -#include - -typedef struct { - zova_database *db; -} cbm_zova_db_t; - -struct cbm_zova_vector_session { - cbm_zova_db_t zdb; - int64_t generation; - bool generation_valid; -}; - -struct cbm_zova_graph_session { - cbm_zova_db_t zdb; - int64_t generation; - bool generation_valid; -}; - -enum { - ZV_CAMEL_SPLIT_BUF = 2048, - ZV_CAMEL_BUF_GUARD = 2, - ZV_GRAPH_HYDRATE_BATCH = 128, -}; - -#define ZV_DENOM_EPS_D 1e-10 - -static char *zv_strndup(const char *data, size_t len) { - char *copy = (char *)malloc(len + 1); - if (!copy) { - return NULL; - } - if (len > 0) { - memcpy(copy, data, len); - } - copy[len] = '\0'; - return copy; -} - -static bool zv_camel_should_split(const char *input, size_t len, size_t i) { - if (i == 0) { - return false; - } - char curr = input[i]; - char prev = input[i - 1]; - char next = i + 1 < len ? input[i + 1] : '\0'; - if (curr >= 'A' && curr <= 'Z' && prev >= 'a' && prev <= 'z') { - return true; - } - if (curr >= 'A' && curr <= 'Z' && prev >= 'A' && prev <= 'Z' && next >= 'a' && - next <= 'z') { - return true; - } - return false; -} - -static void sql_result_int(zova_sql_result *out, int64_t value) { - memset(out, 0, sizeof(*out)); - out->result_type = ZOVA_SQL_RESULT_INTEGER; - out->int64_value = value; -} - -static void sql_result_double(zova_sql_result *out, double value) { - memset(out, 0, sizeof(*out)); - out->result_type = ZOVA_SQL_RESULT_FLOAT; - out->double_value = value; -} - -static void sql_result_text(zova_sql_result *out, const char *data, size_t len) { - memset(out, 0, sizeof(*out)); - out->result_type = ZOVA_SQL_RESULT_TEXT; - out->data = data; - out->data_len = len; -} - -static void sql_result_error(zova_sql_result *out, const char *message) { - memset(out, 0, sizeof(*out)); - out->result_type = ZOVA_SQL_RESULT_ERROR; - out->error_message = message ? message : "zova sql callback error"; - out->error_message_len = strlen(out->error_message); -} - -static const zova_sql_value *sql_arg(const zova_sql_function_call *call, size_t index) { - if (!call || !call->argv || index >= call->argc) { - return NULL; - } - return &call->argv[index]; -} - -static char *sql_text_copy(const zova_sql_value *value) { - if (!value || value->value_type != ZOVA_SQL_VALUE_TEXT) { - return NULL; - } - if (!value->data && value->data_len != 0) { - return NULL; - } - return zv_strndup((const char *)(value->data ? value->data : ""), value->data_len); -} - -static void zova_sql_cosine_i8(void *user_data, const zova_sql_function_call *call, - zova_sql_result *out) { - (void)user_data; - const zova_sql_value *a_value = sql_arg(call, 0); - const zova_sql_value *b_value = sql_arg(call, 1); - if (!a_value || !b_value || a_value->value_type != ZOVA_SQL_VALUE_BLOB || - b_value->value_type != ZOVA_SQL_VALUE_BLOB || a_value->data_len != b_value->data_len || - a_value->data_len == 0 || !a_value->data || !b_value->data) { - sql_result_double(out, 0.0); - return; - } - - const int8_t *a = (const int8_t *)a_value->data; - const int8_t *b = (const int8_t *)b_value->data; - int64_t dot = 0; - int64_t mag_a = 0; - int64_t mag_b = 0; - for (size_t i = 0; i < a_value->data_len; i++) { - dot += (int64_t)a[i] * (int64_t)b[i]; - mag_a += (int64_t)a[i] * (int64_t)a[i]; - mag_b += (int64_t)b[i] * (int64_t)b[i]; - } - - double denom = sqrt((double)mag_a) * sqrt((double)mag_b); - sql_result_double(out, denom > ZV_DENOM_EPS_D ? (double)dot / denom : 0.0); -} - -static void zova_sql_camel_split(void *user_data, const zova_sql_function_call *call, - zova_sql_result *out) { - (void)user_data; - const zova_sql_value *value = sql_arg(call, 0); - if (!value || value->value_type != ZOVA_SQL_VALUE_TEXT) { - sql_result_text(out, "", 0); - return; - } - const char *input = (const char *)(value->data ? value->data : ""); - size_t input_len = value->data_len; - if (input_len == 0) { - sql_result_text(out, "", 0); - return; - } - /* Zova copies the result bytes after this callback returns, so they must - * not live in this stack frame. Callbacks do not re-enter, so one buffer - * per thread is enough. */ - static CBM_TLS char buf[ZV_CAMEL_SPLIT_BUF]; - if (input_len + 1 >= sizeof(buf)) { - sql_result_text(out, input, input_len); - return; - } - memcpy(buf, input, input_len); - size_t len = input_len; - buf[len++] = ' '; - for (size_t i = 0; i < input_len && len < sizeof(buf) - ZV_CAMEL_BUF_GUARD; i++) { - if (zv_camel_should_split(input, input_len, i)) { - buf[len++] = ' '; - } - buf[len++] = input[i]; - } - buf[len] = '\0'; - sql_result_text(out, buf, len); -} - -static void zova_sql_regexp_impl(const zova_sql_function_call *call, zova_sql_result *out, - int flags) { - char *pattern = sql_text_copy(sql_arg(call, 0)); - char *text = sql_text_copy(sql_arg(call, 1)); - if (!pattern || !text) { - free(pattern); - free(text); - sql_result_int(out, 0); - return; - } - - cbm_regex_t re; - if (cbm_regcomp(&re, pattern, flags) != 0) { - free(pattern); - free(text); - sql_result_error(out, "invalid regex"); - return; - } - int matched = cbm_regexec(&re, text, 0, NULL, 0) == 0 ? 1 : 0; - cbm_regfree(&re); - free(pattern); - free(text); - sql_result_int(out, matched); -} - -static void zova_sql_regexp(void *user_data, const zova_sql_function_call *call, - zova_sql_result *out) { - (void)user_data; - zova_sql_regexp_impl(call, out, CBM_REG_EXTENDED | CBM_REG_NOSUB); -} - -static void zova_sql_iregexp(void *user_data, const zova_sql_function_call *call, - zova_sql_result *out) { - (void)user_data; - zova_sql_regexp_impl(call, out, CBM_REG_EXTENDED | CBM_REG_NOSUB | CBM_REG_ICASE); -} - -static int status_ok(zova_status status, zova_database *db, const char *phase) { - if (status == ZOVA_OK) { - return 0; - } - const char *msg = db ? zova_database_last_error_message(db) : NULL; - cbm_log_error("zova.err", "phase", phase ? phase : "unknown", "status", - zova_status_name(status), "msg", msg ? msg : ""); - return -1; -} - -static void close_zova(cbm_zova_db_t *zdb); - -static int register_zova_sql_function(zova_database *db, const char *name, int arity, - zova_sql_scalar_callback callback) { - zova_sql_function_register_request req = { - .db = db, - .name = name, - .arity = arity, - .flags = ZOVA_SQL_FUNCTION_DETERMINISTIC | ZOVA_SQL_FUNCTION_INNOCUOUS, - .user_data = NULL, - .callback = callback, - .destroy = NULL, - }; - zova_status status = zova_database_register_function(&req); - /* Pinned Zova builds report duplicate registration as INVALID_ARGUMENT. - * Treat that one case as success so callers can safely establish the - * callback set on handles opened by another CBM helper. */ - if (status == ZOVA_INVALID_ARGUMENT) { - return 0; - } - return status_ok(status, db, name); -} - -int cbm_zova_register_sql_functions(zova_database *db) { - if (!db) { - return -1; - } - if (register_zova_sql_function(db, "cbm_cosine_i8", 2, zova_sql_cosine_i8) != 0) { - return -1; - } - if (register_zova_sql_function(db, "cbm_camel_split", 1, zova_sql_camel_split) != 0) { - return -1; - } - if (register_zova_sql_function(db, "regexp", 2, zova_sql_regexp) != 0) { - return -1; - } - if (register_zova_sql_function(db, "iregexp", 2, zova_sql_iregexp) != 0) { - return -1; - } - return 0; -} - -static int prepare_zova(zova_database *db, const char *sql, zova_statement **out, - const char *phase); -static int workspace_bind_text(zova_database *db, zova_statement *stmt, int index, - const char *value, const char *phase); -static int workspace_bind_i64(zova_database *db, zova_statement *stmt, int index, - int64_t value, const char *phase); -static char *column_text_owned(zova_database *db, zova_statement *stmt, int column); - -void cbm_zova_graph_adjacency_free(cbm_zova_graph_adjacency_t *items, int count) { - if (!items) return; - for (int i=0;iitems || !context->count || - !context->capacity) return -1; - if (*context->count >= context->max_results) return 0; - if (*context->count == *context->capacity) { - if (*context->capacity > INT_MAX / 2) return -1; - int grown_capacity = *context->capacity * 2; - void *grown = realloc(*context->items, - (size_t)grown_capacity * sizeof(**context->items)); - if (!grown) return -1; - *context->items = grown; - memset(*context->items + *context->capacity, 0, - (size_t)(grown_capacity - *context->capacity) * sizeof(**context->items)); - *context->capacity = grown_capacity; - } - cbm_zova_graph_adjacency_t *item = &(*context->items)[*context->count]; - item->source_node_id = zv_strndup(context->source, strlen(context->source)); - item->target_node_id = zv_strndup(context->target, strlen(context->target)); - item->edge_type = zv_strndup(context->edge_type, context->edge_type_len); - item->properties = zv_strndup((const char *)record->properties.data, - record->properties.len); - if (!item->source_node_id || !item->target_node_id || - !item->edge_type || !item->properties) { - free(item->source_node_id); - free(item->target_node_id); - free(item->edge_type); - free(item->properties); - memset(item, 0, sizeof(*item)); - return -1; - } - (*context->count)++; - return 0; -} - -int cbm_zova_graph_session_adjacency( - cbm_zova_graph_session_t *session, const char *workspace_id, const char *graph_name, - const char *node_id, const char *direction, const char *const *edge_types, - int edge_type_count, int max_results, cbm_zova_graph_adjacency_t **out, int *out_count) { - if (out) *out = NULL; - if (out_count) *out_count = 0; - char expected[CBM_ZOVA_WORKSPACE_ID_MAX + 32]; - if (!session || !workspace_name_component_valid(workspace_id) || - cbm_zova_workspace_graph_name(workspace_id, expected, sizeof(expected)) != 0 || - !graph_name || strcmp(graph_name, expected) != 0 || !node_id || !node_id[0] || - !direction || edge_type_count < 0 || (edge_type_count && !edge_types) || - max_results <= 0 || !out || !out_count) - return -1; - int dirs[2], nd = 0; - if (strcmp(direction, "outbound") == 0 || strcmp(direction, "both") == 0) - dirs[nd++] = ZOVA_GRAPH_NEIGHBOR_OUTGOING; - if (strcmp(direction, "inbound") == 0 || strcmp(direction, "both") == 0) - dirs[nd++] = ZOVA_GRAPH_NEIGHBOR_INCOMING; - if (!nd) return -1; - int loops = edge_type_count ? edge_type_count : 1, cap = 8, count = 0, rc = 0; - cbm_zova_graph_adjacency_t *items = calloc((size_t)cap, sizeof(*items)); - if (!items) return -1; - for (int d = 0; rc == 0 && d < nd; d++) - for (int t = 0; rc == 0 && t < loops; t++) { - zova_graph_keyed_neighbor_results results = {0}; - zova_graph_neighbors_keyed_request req = { - .db = session->zdb.db, .graph_name = graph_name, .node_id = node_id, - .direction = dirs[d], .edge_type = edge_type_count ? edge_types[t] : NULL, - .limit = (size_t)(max_results - count), .out_results = &results}; - if (status_ok(zova_graph_neighbors_keyed(&req), session->zdb.db, - "graph.adjacency") != 0) - rc = -1; - int64_t *edge_keys = - results.len ? calloc(results.len, sizeof(*edge_keys)) : NULL; - zova_graph_edge_payload_results payloads = {0}; - if (rc == 0 && results.len && !edge_keys) rc = -1; - for (size_t i = 0; rc == 0 && i < results.len; i++) - edge_keys[i] = results.items[i].edge_key; - if (rc == 0 && results.len) - rc = status_ok( - zova_graph_edge_payload_get_many( - &(zova_graph_edge_payload_get_many_request){ - .db = session->zdb.db, .graph_name = graph_name, - .edge_keys = edge_keys, .key_count = results.len, - .out_results = &payloads}), - session->zdb.db, "graph.adjacency.payloads"); - if (rc == 0 && payloads.len != results.len) rc = -1; - for (size_t i = 0; rc == 0 && i < results.len && count < max_results; i++) { - const char *src = dirs[d] == ZOVA_GRAPH_NEIGHBOR_OUTGOING - ? node_id - : results.items[i].node_id; - const char *dst = dirs[d] == ZOVA_GRAPH_NEIGHBOR_OUTGOING - ? results.items[i].node_id - : node_id; - if (!payloads.items[i].found || - payloads.items[i].edge_key != results.items[i].edge_key) { - rc = -1; - break; - } - graph_adjacency_payload_context_t context = { - .items = &items, .count = &count, .capacity = &cap, - .max_results = max_results, .source = src, .target = dst, - .edge_type = results.items[i].edge_type, - .edge_type_len = results.items[i].edge_type_len}; - if (cbm_zova_edge_payload_visit( - payloads.items[i].payload, payloads.items[i].payload_len, - graph_adjacency_append_payload, &context, NULL) != 0) - rc = -1; - } - free(edge_keys); - zova_graph_edge_payload_results_free(&payloads); - zova_graph_keyed_neighbor_results_free(&results); - } - if (rc) { - cbm_zova_graph_adjacency_free(items, count + 1); - return -1; - } - *out = items; - *out_count = count; - return 0; -} - -int cbm_zova_graph_session_degree( - cbm_zova_graph_session_t *session, const char *workspace_id, const char *graph_name, - const char *node_id, const char *direction, const char *const *edge_types, - int edge_type_count, int *out_degree) { - if (!out_degree) return -1; - *out_degree = 0; - char expected[CBM_ZOVA_WORKSPACE_ID_MAX + 32]; - if (!session || - cbm_zova_workspace_graph_name(workspace_id, expected, sizeof(expected)) != 0 || - !graph_name || strcmp(graph_name, expected) != 0 || !node_id || !direction || - edge_type_count < 0 || (edge_type_count && !edge_types)) - return -1; - int dirs[2], nd = 0; - if (strcmp(direction, "outbound") == 0 || strcmp(direction, "both") == 0) - dirs[nd++] = ZOVA_GRAPH_NEIGHBOR_OUTGOING; - if (strcmp(direction, "inbound") == 0 || strcmp(direction, "both") == 0) - dirs[nd++] = ZOVA_GRAPH_NEIGHBOR_INCOMING; - if (!nd) return -1; - int loops = edge_type_count ? edge_type_count : 1; - uint64_t total = 0; - for (int d = 0; d < nd; d++) - for (int t = 0; t < loops; t++) { - uint64_t degree = 0; - zova_graph_degree_request req = { - .db = session->zdb.db, .graph_name = graph_name, .node_id = node_id, - .direction = dirs[d], .edge_type = edge_type_count ? edge_types[t] : NULL, - .out_degree = °ree}; - if (status_ok(zova_graph_degree(&req), session->zdb.db, "graph.degree") != 0 || - degree > (uint64_t)INT_MAX - total) - return -1; - total += degree; - } - *out_degree = (int)total; - return 0; -} - -int cbm_zova_graph_session_adjacency_prepare_count( - const cbm_zova_graph_session_t *session) { - (void)session; - return 0; -} - -cbm_zova_database_format_status_t cbm_zova_database_format_status(const char *path) { - if (!path || !path[0]) return CBM_ZOVA_DATABASE_INCOMPATIBLE; - sqlite3 *db = NULL; - sqlite3_stmt *stmt = NULL; - cbm_zova_database_format_status_t result = CBM_ZOVA_DATABASE_INCOMPATIBLE; - if (sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX, NULL) != - SQLITE_OK || - sqlite3_exec(db, "PRAGMA query_only=ON;BEGIN", NULL, NULL, NULL) != SQLITE_OK || - sqlite3_prepare_v2(db, - "SELECT (SELECT value FROM _zova_meta WHERE key='format_version')," - "(SELECT schema_version FROM cbm_database_schema_v1 WHERE id=1)", - -1, &stmt, NULL) != SQLITE_OK || - sqlite3_step(stmt) != SQLITE_ROW || sqlite3_column_type(stmt, 0) != SQLITE_TEXT || - sqlite3_column_type(stmt, 1) != SQLITE_INTEGER) { - goto done; - } - const unsigned char *format = sqlite3_column_text(stmt, 0); - int64_t schema = sqlite3_column_int64(stmt, 1); - if (format && strcmp((const char *)format, "12") == 0 && - schema == CBM_ZOVA_DATABASE_SCHEMA_VERSION) { - result = CBM_ZOVA_DATABASE_COMPATIBLE; - } else if (format && - (strcmp((const char *)format, "9") == 0 || - strcmp((const char *)format, "10") == 0 || - strcmp((const char *)format, "11") == 0) && - schema == CBM_ZOVA_DATABASE_SCHEMA_VERSION) { - result = CBM_ZOVA_DATABASE_MIGRATION_REQUIRED; - } else if (format && strcmp((const char *)format, "7") == 0 && schema == 5) { - result = CBM_ZOVA_DATABASE_REPACK_REQUIRED; - } -done: - sqlite3_finalize(stmt); - if (db) { - (void)sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); - sqlite3_close(db); - } - return result; -} - -const char *cbm_zova_repack_phase_name(cbm_zova_repack_phase_t phase) { - static const char *names[] = { - "source_snapshot", "temp_creation", "workspace_publish", "verification", "fsync", - "live_to_recovery_rename", "temp_to_live_rename", "final_reopen", - }; - return phase >= 0 && phase < CBM_ZOVA_REPACK_PHASE_COUNT ? names[phase] : NULL; -} - -static int open_zova(const char *path, bool read_only, cbm_zova_db_t *out) { - if (!path || !out) { - return -1; - } - memset(out, 0, sizeof(*out)); - cbm_zova_database_format_status_t format_status = cbm_zova_database_format_status(path); - if (format_status == CBM_ZOVA_DATABASE_REPACK_REQUIRED) { - cbm_log_error("zova.open", "path", path, "status", "repack_required"); - return -1; - } - if (format_status == CBM_ZOVA_DATABASE_MIGRATION_REQUIRED) { - cbm_log_error("zova.open", "path", path, "status", "format_migration_required"); - return -1; - } - zova_message err = {0}; - zova_status st; - if (read_only) { - zova_database_open_options_request req = { - .path = path, - .flags = ZOVA_OPEN_READ_ONLY, - .busy_timeout_ms = 5000, - .out_db = &out->db, - .out_error_message = &err, - }; - st = zova_database_open_with_options(&req); - } else { - zova_database_open_request req = { - .path = path, - .out_db = &out->db, - .out_error_message = &err, - }; - st = zova_database_open(&req); - } - if (st != ZOVA_OK) { - cbm_log_error("zova.open", "path", path, "status", zova_status_name(st), "msg", - err.data ? err.data : ""); - zova_message_free(&err); - return -1; - } - user_publish_test_metrics.database_handle_open_count++; - zova_message_free(&err); - if (cbm_zova_register_sql_functions(out->db) != 0 || - zova_database_exec(&(zova_database_exec_request){ - .db = out->db, .sql = "PRAGMA foreign_keys=ON"}) != ZOVA_OK) { - close_zova(out); - return -1; - } - return 0; -} - -/* Execute one capability probe without logging expected feature failures. - * A successful statement with no rows is still a successful capability (for - * example BM25 on an empty FTS table). */ -static int zova_probe_sql(zova_database *db, const char *sql, bool *out_row) { - if (out_row) { - *out_row = false; - } - if (!db || !sql) { - return -1; - } - zova_statement *stmt = NULL; - zova_database_prepare_request prepare_req = {.db = db, .sql = sql, .out_statement = &stmt}; - if (zova_database_prepare(&prepare_req) != ZOVA_OK || !stmt) { - return -1; - } - zova_step_result step = ZOVA_STEP_DONE; - zova_statement_step_request step_req = {.statement = stmt, .out_result = &step}; - int rc = zova_statement_step(&step_req) == ZOVA_OK ? 0 : -1; - if (rc == 0 && out_row) { - *out_row = step == ZOVA_STEP_ROW; - } - if (zova_statement_finalize(stmt) != ZOVA_OK) { - rc = -1; - } - return rc; -} - -int cbm_zova_probe_sql_capabilities(const char *zova_path, - cbm_zova_sql_capabilities_t *out_capabilities) { - if (!zova_path || !zova_path[0] || !out_capabilities) { - return -1; - } - memset(out_capabilities, 0, sizeof(*out_capabilities)); - cbm_zova_db_t zdb; - if (open_zova(zova_path, false, &zdb) != 0) { - return -1; - } - - bool row = false; - out_capabilities->canonical_workspace_metadata = - zova_probe_sql(zdb.db, - "SELECT 1 FROM sqlite_master WHERE type='table' AND " - "name='cbm_nodes_v1' LIMIT 1", - &row) == 0 && - row; - - /* Exercise the complete FTS surface on a disposable table instead of - * relying on a converted sidecar containing a particular application - * table. This covers creation, insert, MATCH, update and delete. */ - const char *fts_setup = - "DROP TABLE IF EXISTS cbm_zova_capability_fts_v1;" - "CREATE VIRTUAL TABLE cbm_zova_capability_fts_v1 USING fts5(" - "workspace_id UNINDEXED,node_id UNINDEXED,name,qualified_name,file_path,label," - "tokenize='unicode61 remove_diacritics 2');" - "INSERT INTO cbm_zova_capability_fts_v1" - "(workspace_id,node_id,name,qualified_name,file_path,label) " - "VALUES('w1','n1','AlphaBeta','pkg::AlphaBeta','src/a.c','Function');"; - zova_database_exec_request fts_req = {.db = zdb.db, .sql = fts_setup}; - zova_status fts_status = zova_database_exec(&fts_req); - bool fts_surface = fts_status == ZOVA_OK; - if (!fts_surface) { - cbm_log_error("zova.capability", "feature", "fts5", "phase", "create_insert", - "status", zova_status_name(fts_status), "msg", - zova_database_last_error_message(zdb.db)); - } - if (fts_surface) { - fts_surface = zova_probe_sql( - zdb.db, - "SELECT rowid FROM cbm_zova_capability_fts_v1 " - "WHERE cbm_zova_capability_fts_v1 MATCH 'AlphaBeta' LIMIT 1", - &row) == 0 && - row; - } - if (fts_surface) { - const char *fts_update = - "UPDATE cbm_zova_capability_fts_v1 SET name='BetaGamma' WHERE rowid=1;" - "DELETE FROM cbm_zova_capability_fts_v1 WHERE rowid=1;"; - zova_database_exec_request update_req = {.db = zdb.db, .sql = fts_update}; - zova_status update_status = zova_database_exec(&update_req); - fts_surface = update_status == ZOVA_OK && - zova_probe_sql(zdb.db, - "SELECT count(*) FROM cbm_zova_capability_fts_v1", - &row) == 0 && - row; - if (!fts_surface && update_status != ZOVA_OK) { - cbm_log_error("zova.capability", "feature", "fts5", "phase", "update_delete", - "status", zova_status_name(update_status), "msg", - zova_database_last_error_message(zdb.db)); - } - } - out_capabilities->fts5 = fts_surface; - if (fts_surface) { - zova_database_exec_request bm25_insert = { - .db = zdb.db, - .sql = "INSERT INTO cbm_zova_capability_fts_v1" - "(workspace_id,node_id,name,qualified_name,file_path,label) " - "VALUES('w1','n2','Alpha','pkg::Alpha','src/b.c','Function');"}; - fts_surface = zova_database_exec(&bm25_insert) == ZOVA_OK; - } - out_capabilities->bm25 = fts_surface && - zova_probe_sql( - zdb.db, - "SELECT bm25(cbm_zova_capability_fts_v1) " - "FROM cbm_zova_capability_fts_v1 " - "WHERE cbm_zova_capability_fts_v1 MATCH 'Alpha' LIMIT 1", - &row) == 0 && - row; - (void)zova_database_exec(&(zova_database_exec_request){ - .db = zdb.db, .sql = "DROP TABLE IF EXISTS cbm_zova_capability_fts_v1;"}); - - out_capabilities->json = - zova_probe_sql(zdb.db, - "SELECT json_extract('{\"workspace_id\":\"w1\",\"value\":1}', " - "'$.value') WHERE json_extract('{\"workspace_id\":\"w1\",\"value\":1}', " - "'$.workspace_id') = 'w1'", - &row) == 0 && - row; - out_capabilities->recursive_cte = - zova_probe_sql(zdb.db, - "WITH RECURSIVE n(workspace_id,x) AS (VALUES('w1',1) UNION ALL " - "SELECT workspace_id,x+1 FROM n WHERE workspace_id='w1' AND x<2) " - "SELECT count(*) FROM n WHERE workspace_id='w1'", - &row) == 0 && - row; - - const char *function_probes[] = { - "SELECT cbm_camel_split('AlphaBeta')", - "SELECT regexp('alpha', 'Alpha')", - "SELECT iregexp('alpha', 'Alpha')", - "SELECT cbm_cosine_i8(x'01', x'01')", - }; - out_capabilities->cbm_scalar_functions = true; - for (size_t i = 0; i < sizeof(function_probes) / sizeof(function_probes[0]); i++) { - if (zova_probe_sql(zdb.db, function_probes[i], &row) != 0 || !row) { - out_capabilities->cbm_scalar_functions = false; - break; - } - } - - zova_database_exec_request transaction_req = { - .db = zdb.db, - .sql = "BEGIN; CREATE TEMP TABLE cbm_zova_capability_probe(x INTEGER); " - "INSERT INTO cbm_zova_capability_probe VALUES(1); ROLLBACK;", - }; - out_capabilities->transactions = zova_database_exec(&transaction_req) == ZOVA_OK; - close_zova(&zdb); - return 0; -} - -static void close_zova(cbm_zova_db_t *zdb) { - if (zdb && zdb->db) { - (void)zova_database_close(zdb->db); - zdb->db = NULL; - } -} - -static int prepare_zova(zova_database *db, const char *sql, zova_statement **out, - const char *phase) { - zova_database_prepare_request req = {.db = db, .sql = sql, .out_statement = out}; - return status_ok(zova_database_prepare(&req), db, phase); -} - -static int configure_new_zova_database(zova_database *db) { - zova_statement *stmt = NULL; - int64_t page_size = 0; - int rc = status_ok(zova_database_exec(&(zova_database_exec_request){ - .db = db, - .sql = "PRAGMA journal_mode=WAL"}), - db, "zova.journal_mode_configure"); - if (rc == 0) - rc = prepare_zova(db, "PRAGMA page_size", &stmt, "zova.page_size_prepare"); - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0) - rc = status_ok(zova_statement_step(&(zova_statement_step_request){ - .statement = stmt, .out_result = &step}), - db, "zova.page_size_step"); - if (rc == 0 && step == ZOVA_STEP_ROW) - rc = status_ok(zova_statement_column_int64( - &(zova_statement_column_int64_request){ - .statement = stmt, .index = 0, .out_value = &page_size}), - db, "zova.page_size_column"); - else if (rc == 0) - rc = -1; - if (stmt && zova_statement_finalize(stmt) != ZOVA_OK) rc = -1; - return rc == 0 && page_size == 65536 ? 0 : -1; -} - -static int open_or_create_zova(const char *path, cbm_zova_db_t *out) { - bool exists = cbm_file_exists(path); - if (exists) { - return open_zova(path, false, out); - } - memset(out, 0, sizeof(*out)); - zova_message err = {0}; - zova_database_create_options_request req = { - .path = path, - .page_size = 65536, - .out_db = &out->db, - .out_error_message = &err, - }; - zova_status status = zova_database_create_with_options(&req); - if (status != ZOVA_OK) { - cbm_log_error("zova.registry_create", "path", path, "status", zova_status_name(status), - "msg", err.data ? err.data : ""); - zova_message_free(&err); - return -1; - } - user_publish_test_metrics.database_handle_open_count++; - zova_message_free(&err); - /* Zova selected 64 KiB pages before creating its private catalog. Keep the - * new handle, enable WAL, and verify the effective page size in place. */ - if (configure_new_zova_database(out->db) != 0) { - close_zova(out); - cbm_unlink(path); - return -1; - } - if (cbm_zova_register_sql_functions(out->db) != 0 || - zova_database_exec(&(zova_database_exec_request){ - .db = out->db, .sql = "PRAGMA foreign_keys=ON"}) != ZOVA_OK) { - close_zova(out); - cbm_unlink(path); - return -1; - } - return 0; -} - -static int workspace_registry_init(zova_database *db) { - const char *sql = - "CREATE TABLE IF NOT EXISTS cbm_workspace_registry(" - "workspace_key INTEGER PRIMARY KEY, workspace_id TEXT NOT NULL UNIQUE, " - "canonical_root TEXT NOT NULL UNIQUE, " - "id_format_version INTEGER NOT NULL, active_generation INTEGER NOT NULL DEFAULT 0);"; - zova_database_exec_request req = {.db = db, .sql = sql}; - return status_ok(zova_database_exec(&req), db, "workspace.registry_init"); -} - -static int workspace_legacy_generation_init(zova_database *db) { - /* The normal single-file schema uses cbm_database_generation_v1. Keep this - * table out of fresh databases unless an old sidecar coordination API is - * explicitly invoked. */ - const char *sql = - "CREATE TABLE IF NOT EXISTS cbm_workspace_generations(" - "workspace_key INTEGER NOT NULL, generation INTEGER NOT NULL, state TEXT NOT NULL " - "CHECK(state IN ('building','ready','failed','retired')), " - "PRIMARY KEY(workspace_key,generation), " - "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " - "ON DELETE CASCADE) WITHOUT ROWID;"; - zova_database_exec_request req = {.db = db, .sql = sql}; - return status_ok(zova_database_exec(&req), db, "workspace.legacy_generation_init"); -} - -static int workspace_registry_begin(zova_database *db) { - zova_database_simple_request req = {.db = db}; - return status_ok(zova_database_begin_immediate(&req), db, "workspace.begin"); -} - -static int workspace_registry_commit(zova_database *db) { - zova_database_simple_request req = {.db = db}; - return status_ok(zova_database_commit(&req), db, "workspace.commit"); -} - -static void workspace_registry_rollback(zova_database *db) { - zova_database_simple_request req = {.db = db}; - (void)zova_database_rollback(&req); -} - -/* These bind/step helpers are defined below the workspace registry code; the - * user-database generation API is kept beside the schema bootstrap for the - * migration boundary, so declare them before using them here. */ -static int workspace_bind_text(zova_database *db, zova_statement *stmt, int index, - const char *value, const char *phase); -static int workspace_bind_i64(zova_database *db, zova_statement *stmt, int index, int64_t value, - const char *phase); -static int workspace_step_done(zova_database *db, zova_statement *stmt, const char *phase); - -static int user_schema_scalar(zova_database *db, const char *sql, int64_t *out_value) { - zova_statement *stmt = NULL; - if (!db || !sql || !out_value || - prepare_zova(db, sql, &stmt, "user_database.schema_probe") != 0) { - return -1; - } - zova_step_result step = ZOVA_STEP_DONE; - int rc = zova_statement_step(&(zova_statement_step_request){ - .statement = stmt, .out_result = &step}) == ZOVA_OK && - step == ZOVA_STEP_ROW && - zova_statement_column_int64(&(zova_statement_column_int64_request){ - .statement = stmt, .index = 0, .out_value = out_value}) == ZOVA_OK - ? 0 - : -1; - (void)zova_statement_finalize(stmt); - return rc; -} - -static int user_schema_v3_is_well_formed(zova_database *db) { - int64_t table_count = 0; - const char *sql = - "SELECT count(*) FROM sqlite_master WHERE type IN ('table','view') AND name IN (" - "'cbm_workspace_registry','cbm_database_generation_v1'," - "'cbm_projects_v1','cbm_files_v1','cbm_nodes_v1','cbm_file_hashes_v1'," - "'cbm_token_vector_metadata_v1','cbm_nodes_fts_v1'," - "'cbm_workspace_index_state_v1','cbm_project_summaries_v2'," - "'cbm_generation_integrity_v2')"; - return user_schema_scalar(db, sql, &table_count) == 0 && table_count == 11 ? 0 : -1; -} - -static int user_schema_v4_is_well_formed(zova_database *db) { - int64_t columns = 0; - return user_schema_v3_is_well_formed(db) == 0 && - user_schema_scalar( - db, - "SELECT count(*) FROM pragma_table_info('cbm_workspace_migrations_v1')", - &columns) == 0 && - columns == 17 - ? 0 - : -1; -} - -int cbm_zova_user_database_schema_is_current(zova_database *db) { - int64_t columns = 0; - int64_t exact_columns = 0; - int64_t foreign_keys = 0; - int64_t state_constraint = 0; - int64_t token_columns = 0; - int64_t forbidden_objects = 0; - int64_t compact_foreign_keys = 0; - int64_t compact_child_indexes = 0; - int64_t guard_triggers = 0; - int64_t topology_columns = 0; - return user_schema_v4_is_well_formed(db) == 0 && - user_schema_scalar( - db, - "SELECT count(*) FROM pragma_table_info('cbm_workspace_health_v1')", - &columns) == 0 && - columns == 5 && - user_schema_scalar( - db, - "SELECT count(*) FROM pragma_table_info('cbm_workspace_health_v1') " - "WHERE (cid=0 AND name='workspace_key' AND type='INTEGER' AND pk=1 AND " - "dflt_value IS NULL) OR " - "(cid=1 AND name='state' AND type='TEXT' AND \"notnull\"=1 AND pk=0 AND " - "dflt_value IS NULL) OR " - "(cid=2 AND name='reason' AND type='TEXT' AND \"notnull\"=1 AND pk=0 AND " - "dflt_value IS NULL) OR " - "(cid=3 AND name='checked_generation' AND type='INTEGER' AND \"notnull\"=1 " - "AND pk=0 AND dflt_value IS NULL) OR " - "(cid=4 AND name='checked_at' AND type='TEXT' AND \"notnull\"=1 AND pk=0 AND " - "dflt_value IS NULL)", - &exact_columns) == 0 && - exact_columns == 5 && - user_schema_scalar( - db, - "SELECT count(*) FROM pragma_foreign_key_list(" - "'cbm_workspace_health_v1') WHERE \"table\"='cbm_workspace_registry' " - "AND \"from\"='workspace_key' AND \"to\"='workspace_key'", - &foreign_keys) == 0 && - foreign_keys == 1 && - user_schema_scalar( - db, - "SELECT count(*) FROM sqlite_master WHERE type='table' AND " - "name='cbm_workspace_health_v1' AND " - "instr(sql,\"state IN ('healthy','rebuild_required')\") > 0", - &state_constraint) == 0 && - state_constraint == 1 && - user_schema_scalar( - db, - "SELECT count(*) FROM pragma_table_info(" - "'cbm_token_vector_metadata_v1') WHERE " - "(cid=0 AND name='token_key' AND type='INTEGER' AND pk=1) OR " - "(cid=1 AND name='workspace_key' AND type='INTEGER' AND \"notnull\"=1) OR " - "(cid=2 AND name='token' AND type='TEXT' AND \"notnull\"=1) OR " - "(cid=3 AND name='idf' AND type='REAL' AND \"notnull\"=1)", - &token_columns) == 0 && - token_columns == 4 && - user_schema_scalar( - db, - "SELECT count(*) FROM (" - "SELECT 1 FROM pragma_table_info('cbm_nodes_v1') WHERE " - "(cid=0 AND name='zova_node_key' AND type='INTEGER' AND pk=1) OR " - "(cid=1 AND name='workspace_key' AND type='INTEGER' AND \"notnull\"=1) OR " - "(cid=2 AND name='label' AND type='TEXT' AND \"notnull\"=1) OR " - "(cid=3 AND name='name' AND type='TEXT' AND \"notnull\"=1) OR " - "(cid=4 AND name='qualified_name' AND type='TEXT' AND \"notnull\"=1) OR " - "(cid=5 AND name='file_key' AND type='INTEGER' AND \"notnull\"=1) OR " - "(cid=6 AND name='start_line' AND type='INTEGER' AND \"notnull\"=1) OR " - "(cid=7 AND name='end_line' AND type='INTEGER' AND \"notnull\"=1) OR " - "(cid=8 AND name='properties' AND type='TEXT' AND \"notnull\"=1) OR " - "(cid=9 AND name='source_ordinal' AND type='INTEGER' AND \"notnull\"=1))", - &topology_columns) == 0 && - topology_columns == 10 && - user_schema_scalar( - db, - "SELECT count(*) FROM (" - "SELECT 1 FROM pragma_foreign_key_list('cbm_nodes_v1') " - "WHERE \"table\"='cbm_files_v1' AND ((seq=0 AND \"from\"='workspace_key' " - "AND \"to\"='workspace_key') OR (seq=1 AND \"from\"='file_key' " - "AND \"to\"='file_key')))", - &compact_foreign_keys) == 0 && - compact_foreign_keys == 2 && - user_schema_scalar( - db, - "SELECT count(*) FROM sqlite_master WHERE type='index' AND " - "name='cbm_nodes_v1_workspace_file'", - &compact_child_indexes) == 0 && - compact_child_indexes == 1 && - user_schema_scalar( - db, - "SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name IN (" - "'cbm_nodes_v1_file_workspace_bi','cbm_nodes_v1_file_workspace_bu'," - "'cbm_edges_v1_endpoint_workspace_bi'," - "'cbm_edges_v1_endpoint_workspace_bu')", - &guard_triggers) == 0 && - guard_triggers == 0 && - user_schema_scalar( - db, - "SELECT count(*) FROM sqlite_master WHERE " - "name IN ('cbm_edges_v1','cbm_zova_trace_nodes_v1','cbm_zova_edge_metadata_v1'," - "'cbm_fts_rowmap_v1','cbm_node_vectors_compat_v1'," - "'cbm_token_vectors_compat_v1','_zova_vector_norms') " - "OR name GLOB 'cbm_fts_w1_*'", - &forbidden_objects) == 0 && - forbidden_objects == 0 - ? 0 - : -1; -} - -static int user_database_ensure_schema(zova_database *db) { - if (!db) return -1; - int64_t schema_table_count = 0; - int64_t schema_version = 0; - if (user_schema_scalar( - db, - "SELECT count(*) FROM sqlite_master WHERE type='table' " - "AND name='cbm_database_schema_v1'", - &schema_table_count) != 0 || - (schema_table_count != 0 && schema_table_count != 1)) { - return -1; - } - if (schema_table_count == 1 && - (user_schema_scalar(db, - "SELECT schema_version FROM cbm_database_schema_v1 WHERE id=1", - &schema_version) != 0 || - schema_version < 1 || schema_version > CBM_ZOVA_DATABASE_SCHEMA_VERSION)) { - return -1; - } - if (schema_version == CBM_ZOVA_DATABASE_SCHEMA_VERSION) { - return cbm_zova_user_database_schema_is_current(db); - } - /* Every pre-v7 file is immutable repack input. Never run partial in-place - * DDL against a live v5/Zova-v7 database. */ - if (schema_table_count == 1) { - return -1; - } - if (workspace_registry_begin(db) != 0 || workspace_registry_init(db) != 0) { - workspace_registry_rollback(db); - return -1; - } - - const char *sql = - "CREATE TABLE IF NOT EXISTS cbm_database_schema_v1(" - "id INTEGER PRIMARY KEY CHECK(id = 1)," - "schema_version INTEGER NOT NULL," - "metadata_projection_version INTEGER NOT NULL," - "edge_metadata_projection_version INTEGER NOT NULL," - "created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);" - "INSERT OR IGNORE INTO cbm_database_schema_v1" - "(id,schema_version,metadata_projection_version,edge_metadata_projection_version)" - "VALUES(1,7,0,0);" - "CREATE TABLE IF NOT EXISTS cbm_database_generation_v1(" - "workspace_key INTEGER NOT NULL, generation INTEGER NOT NULL," - "state TEXT NOT NULL CHECK(state IN ('building','ready','failed','retired'))," - "PRIMARY KEY(workspace_key,generation)," - "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " - "ON DELETE CASCADE) WITHOUT ROWID;" - "CREATE TABLE IF NOT EXISTS cbm_projects_v1(" - "workspace_key INTEGER PRIMARY KEY, project TEXT NOT NULL, root_path TEXT NOT NULL," - "indexed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP," - "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " - "ON DELETE CASCADE);" - "CREATE TABLE IF NOT EXISTS cbm_files_v1(" - "file_key INTEGER PRIMARY KEY, workspace_key INTEGER NOT NULL, file_path TEXT NOT NULL," - "UNIQUE(workspace_key,file_path),UNIQUE(workspace_key,file_key)," - "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " - "ON DELETE CASCADE);" - "CREATE TABLE IF NOT EXISTS cbm_nodes_v1(" - "zova_node_key INTEGER PRIMARY KEY, workspace_key INTEGER NOT NULL," - "label TEXT NOT NULL, name TEXT NOT NULL, qualified_name TEXT NOT NULL," - "file_key INTEGER NOT NULL, start_line INTEGER NOT NULL, end_line INTEGER NOT NULL," - "properties TEXT NOT NULL DEFAULT '{}',source_ordinal INTEGER NOT NULL DEFAULT 0 " - "CHECK(source_ordinal>=0)," - "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " - "ON DELETE CASCADE," - "FOREIGN KEY(workspace_key,file_key) " - "REFERENCES cbm_files_v1(workspace_key,file_key));" - "CREATE INDEX IF NOT EXISTS cbm_nodes_v1_workspace_file " - "ON cbm_nodes_v1(workspace_key,file_key);" - "CREATE TABLE IF NOT EXISTS cbm_file_hashes_v1(" - "file_key INTEGER PRIMARY KEY, content_hash TEXT NOT NULL," - "mtime_ns INTEGER NOT NULL DEFAULT 0, size_bytes INTEGER NOT NULL DEFAULT 0," - "FOREIGN KEY(file_key) REFERENCES cbm_files_v1(file_key) ON DELETE CASCADE);" - "CREATE TABLE IF NOT EXISTS cbm_token_vector_metadata_v1(" - "token_key INTEGER PRIMARY KEY, workspace_key INTEGER NOT NULL," - "token TEXT NOT NULL, idf REAL NOT NULL, UNIQUE(workspace_key,token)," - "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " - "ON DELETE CASCADE);" - "CREATE VIEW IF NOT EXISTS cbm_nodes_fts_content_v1 AS " - "SELECT n.zova_node_key,cbm_camel_split(n.name) AS name,n.qualified_name,f.file_path,n.label " - "FROM cbm_nodes_v1 n JOIN cbm_files_v1 f ON f.file_key=n.file_key;" - "CREATE VIRTUAL TABLE IF NOT EXISTS cbm_nodes_fts_v1 USING fts5(" - "name,qualified_name,file_path,label,content='cbm_nodes_fts_content_v1'," - "content_rowid='zova_node_key');" - CBM_NODE_FTS_INSERT_TRIGGER_SQL - "CREATE TRIGGER IF NOT EXISTS cbm_nodes_v1_fts_bd BEFORE DELETE ON cbm_nodes_v1 BEGIN " - "INSERT INTO cbm_nodes_fts_v1(cbm_nodes_fts_v1,rowid,name,qualified_name,file_path,label) " - "SELECT 'delete',OLD.zova_node_key,cbm_camel_split(OLD.name),OLD.qualified_name,f.file_path,OLD.label " - "FROM cbm_files_v1 f WHERE f.file_key=OLD.file_key; END;" - "CREATE TRIGGER IF NOT EXISTS cbm_nodes_v1_fts_bu " - "BEFORE UPDATE OF name,qualified_name,file_key,label ON cbm_nodes_v1 BEGIN " - "INSERT INTO cbm_nodes_fts_v1(cbm_nodes_fts_v1,rowid,name,qualified_name,file_path,label) " - "SELECT 'delete',OLD.zova_node_key,cbm_camel_split(OLD.name),OLD.qualified_name,f.file_path,OLD.label " - "FROM cbm_files_v1 f WHERE f.file_key=OLD.file_key; END;" - "CREATE TRIGGER IF NOT EXISTS cbm_nodes_v1_fts_au " - "AFTER UPDATE OF name,qualified_name,file_key,label ON cbm_nodes_v1 BEGIN " - "INSERT INTO cbm_nodes_fts_v1(rowid,name,qualified_name,file_path,label) " - "SELECT NEW.zova_node_key,cbm_camel_split(NEW.name),NEW.qualified_name,f.file_path,NEW.label " - "FROM cbm_files_v1 f WHERE f.file_key=NEW.file_key; END;" - "CREATE TABLE IF NOT EXISTS cbm_workspace_index_state_v1(" - "workspace_key INTEGER PRIMARY KEY, generation INTEGER NOT NULL," - "model_fingerprint TEXT NOT NULL, vector_dimensions INTEGER NOT NULL," - "indexed_at TEXT NOT NULL," - "FOREIGN KEY(workspace_key,generation) REFERENCES cbm_database_generation_v1(workspace_key,generation) " - "ON DELETE CASCADE);" - "CREATE TABLE IF NOT EXISTS cbm_project_summaries_v2(" - "workspace_key INTEGER PRIMARY KEY, summary TEXT NOT NULL, source_hash TEXT NOT NULL," - "created_at TEXT NOT NULL, updated_at TEXT NOT NULL," - "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " - "ON DELETE CASCADE);" - "CREATE TABLE IF NOT EXISTS cbm_generation_integrity_v2(" - "workspace_key INTEGER NOT NULL, generation INTEGER NOT NULL," - "graph_nodes INTEGER NOT NULL, graph_edges INTEGER NOT NULL," - "metadata_nodes INTEGER NOT NULL, metadata_edges INTEGER NOT NULL," - "metadata_topology_edges INTEGER NOT NULL, fts_rows INTEGER NOT NULL," - "node_vector_rows INTEGER NOT NULL, token_vector_rows INTEGER NOT NULL," - "node_vectors INTEGER NOT NULL, token_vectors INTEGER NOT NULL," - "metadata_sha256 TEXT NOT NULL, fts_sha256 TEXT NOT NULL," - "topology_sha256 TEXT NOT NULL, node_vector_sha256 TEXT NOT NULL," - "token_vector_sha256 TEXT NOT NULL," - "PRIMARY KEY(workspace_key,generation)," - "FOREIGN KEY(workspace_key,generation) REFERENCES cbm_database_generation_v1(workspace_key,generation) " - "ON DELETE CASCADE) WITHOUT ROWID;" - "CREATE TABLE IF NOT EXISTS cbm_workspace_migrations_v1(" - "workspace_key INTEGER PRIMARY KEY," - "migration_version INTEGER NOT NULL CHECK(migration_version=1)," - "project TEXT NOT NULL,root_path TEXT NOT NULL," - "source_db_path TEXT NOT NULL,source_zova_path TEXT NOT NULL," - "source_generation INTEGER NOT NULL,target_generation INTEGER NOT NULL DEFAULT 0," - "state TEXT NOT NULL CHECK(state IN ('prepared','copying','active','failed'," - "'rolled_back','cleanup_pending','retired'))," - "metadata_sha256 TEXT NOT NULL,fts_sha256 TEXT NOT NULL," - "topology_sha256 TEXT NOT NULL,node_vector_sha256 TEXT NOT NULL," - "token_vector_sha256 TEXT NOT NULL,prepared_at TEXT NOT NULL," - "activated_at TEXT,retired_at TEXT," - "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " - "ON DELETE CASCADE);" - "CREATE TABLE IF NOT EXISTS cbm_workspace_health_v1(" - "workspace_key INTEGER PRIMARY KEY," - "state TEXT NOT NULL CHECK(state IN ('healthy','rebuild_required'))," - "reason TEXT NOT NULL,checked_generation INTEGER NOT NULL,checked_at TEXT NOT NULL," - "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " - "ON DELETE CASCADE);"; - - zova_database_exec_request req = {.db = db, .sql = sql}; - int rc = status_ok(zova_database_exec(&req), db, "user_database.schema_init"); - const char *fault = getenv("CBM_ZOVA_TEST_FAIL_PHASE"); - if (rc == 0 && fault && strcmp(fault, "schema_v6_before_commit") == 0) { - rc = -1; - } - if (rc == 0) { - rc = workspace_registry_commit(db); - } - if (rc != 0) { - workspace_registry_rollback(db); - } - return rc; -} - -/* Bootstrap the SQL portion of the user-local cbm.zova database. Publication - * callers reuse user_database_ensure_schema() on their existing handle. */ -int cbm_zova_user_database_init(const char *zova_path) { - if (!zova_path || !zova_path[0]) return -1; - cbm_zova_db_t zdb; - if (open_or_create_zova(zova_path, &zdb) != 0) return -1; - user_publish_test_metrics.database_open_count++; - int rc = user_database_ensure_schema(zdb.db); - close_zova(&zdb); - user_publish_test_metrics.database_close_count++; - return rc; -} - -int cbm_zova_user_database_verify_workspace_schema(zova_database *db) { - if (!db) return -1; - const struct { const char *sql; int64_t expected; } checks[] = { - {"PRAGMA foreign_keys", 1}, - {"SELECT schema_version FROM cbm_database_schema_v1 WHERE id=1", - CBM_ZOVA_DATABASE_SCHEMA_VERSION}, - {"SELECT count(*) FROM pragma_foreign_key_list('cbm_nodes_v1') " - "WHERE \"table\"='cbm_files_v1'", 2}, - }; - for (size_t i = 0; i < sizeof(checks)/sizeof(checks[0]); i++) { - zova_statement *stmt = NULL; - if (prepare_zova(db, checks[i].sql, &stmt, "workspace_schema.verify_prepare") != 0) - return -1; - zova_step_result step = ZOVA_STEP_DONE; - int64_t value = 0; - int rc = zova_statement_step(&(zova_statement_step_request){ - .statement=stmt,.out_result=&step}) == ZOVA_OK && step == ZOVA_STEP_ROW && - zova_statement_column_int64(&(zova_statement_column_int64_request){ - .statement=stmt,.index=0,.out_value=&value}) == ZOVA_OK && - value == checks[i].expected ? 0 : -1; - (void)zova_statement_finalize(stmt); - if (rc != 0) return -1; - } - bool violation = false; - return zova_probe_sql(db, "PRAGMA foreign_key_check", &violation) == 0 && !violation ? 0 : -1; -} - -int cbm_zova_user_database_generation_begin(const char *zova_path, const char *root_path, - int64_t generation, char *out_workspace_id, - size_t out_workspace_id_size) { - if (!zova_path || !zova_path[0] || !root_path || !root_path[0] || generation <= 0 || - !out_workspace_id || out_workspace_id_size < CBM_ZOVA_WORKSPACE_ID_MAX) { - return -1; - } - out_workspace_id[0] = '\0'; - if (cbm_zova_user_database_init(zova_path) != 0 || - cbm_zova_workspace_get_or_create_at(zova_path, root_path, out_workspace_id, - out_workspace_id_size) != 0) { - return -1; - } - - cbm_zova_db_t zdb; - if (open_or_create_zova(zova_path, &zdb) != 0 || workspace_registry_begin(zdb.db) != 0) { - close_zova(&zdb); - return -1; - } - zova_statement *stmt = NULL; - int rc = prepare_zova( - zdb.db, - "INSERT INTO cbm_database_generation_v1(workspace_key,generation,state) " - "SELECT workspace_key,?2,'building' FROM cbm_workspace_registry WHERE workspace_id=?1 " - "ON CONFLICT(workspace_key,generation) DO UPDATE SET state='building'", - &stmt, "user_generation.begin_prepare"); - if (rc == 0) - rc = workspace_bind_text(zdb.db, stmt, 1, out_workspace_id, - "user_generation.begin_bind_workspace"); - if (rc == 0) - rc = workspace_bind_i64(zdb.db, stmt, 2, generation, - "user_generation.begin_bind_generation"); - if (rc == 0) - rc = workspace_step_done(zdb.db, stmt, "user_generation.begin_step"); - if (stmt) - (void)zova_statement_finalize(stmt); - if (rc == 0) - rc = workspace_registry_commit(zdb.db); - if (rc != 0) - workspace_registry_rollback(zdb.db); - close_zova(&zdb); - if (rc != 0) - out_workspace_id[0] = '\0'; - return rc; -} - -int cbm_zova_user_database_generation_finish(const char *zova_path, const char *workspace_id, - int64_t generation, bool ready) { - if (!zova_path || !zova_path[0] || !workspace_name_component_valid(workspace_id) || - generation <= 0) { - return -1; - } - cbm_zova_db_t zdb; - if (open_or_create_zova(zova_path, &zdb) != 0 || workspace_registry_begin(zdb.db) != 0) { - close_zova(&zdb); - return -1; - } - - int rc = -1; - zova_statement *stmt = NULL; - rc = prepare_zova(zdb.db, - "SELECT state FROM cbm_database_generation_v1 " - "WHERE workspace_key=(SELECT workspace_key FROM cbm_workspace_registry " - "WHERE workspace_id=?1) AND generation=?2", - &stmt, "user_generation.finish_lookup_prepare"); - if (rc == 0) - rc = workspace_bind_text(zdb.db, stmt, 1, workspace_id, - "user_generation.finish_lookup_bind_workspace"); - if (rc == 0) - rc = workspace_bind_i64(zdb.db, stmt, 2, generation, - "user_generation.finish_lookup_bind_generation"); - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0) { - zova_statement_step_request req = {.statement = stmt, .out_result = &step}; - rc = status_ok(zova_statement_step(&req), zdb.db, "user_generation.finish_lookup_step"); - } - char state[16] = {0}; - if (rc == 0 && step == ZOVA_STEP_ROW) { - zova_text text = {0}; - zova_statement_column_text_request req = { - .statement = stmt, .index = 0, .out_text = &text}; - rc = status_ok(zova_statement_column_text(&req), zdb.db, - "user_generation.finish_lookup_column"); - if (rc == 0 && text.data && text.len < sizeof(state)) { - memcpy(state, text.data, text.len); - state[text.len] = '\0'; - } else if (rc == 0) { - rc = -1; - } - zova_text_free(&text); - } else if (rc == 0) { - rc = -1; - } - if (stmt) - (void)zova_statement_finalize(stmt); - stmt = NULL; - if (rc == 0 && strcmp(state, "building") != 0) - rc = -1; - - const char *next_state = ready ? "ready" : "failed"; - if (rc == 0) - rc = prepare_zova(zdb.db, - "UPDATE cbm_database_generation_v1 SET state=?1 " - "WHERE workspace_key=(SELECT workspace_key FROM cbm_workspace_registry " - "WHERE workspace_id=?2) AND generation=?3", - &stmt, "user_generation.finish_update_prepare"); - if (rc == 0) - rc = workspace_bind_text(zdb.db, stmt, 1, next_state, - "user_generation.finish_update_bind_state"); - if (rc == 0) - rc = workspace_bind_text(zdb.db, stmt, 2, workspace_id, - "user_generation.finish_update_bind_workspace"); - if (rc == 0) - rc = workspace_bind_i64(zdb.db, stmt, 3, generation, - "user_generation.finish_update_bind_generation"); - if (rc == 0) - rc = workspace_step_done(zdb.db, stmt, "user_generation.finish_update_step"); - if (stmt) - (void)zova_statement_finalize(stmt); - stmt = NULL; - if (rc == 0 && ready) - rc = prepare_zova(zdb.db, - "UPDATE cbm_workspace_registry SET active_generation=?1 " - "WHERE workspace_id=?2", - &stmt, "user_generation.finish_active_prepare"); - if (rc == 0 && ready) - rc = workspace_bind_i64(zdb.db, stmt, 1, generation, - "user_generation.finish_active_bind_generation"); - if (rc == 0 && ready) - rc = workspace_bind_text(zdb.db, stmt, 2, workspace_id, - "user_generation.finish_active_bind_workspace"); - if (rc == 0 && ready) - rc = workspace_step_done(zdb.db, stmt, "user_generation.finish_active_step"); - if (stmt) - (void)zova_statement_finalize(stmt); - if (rc == 0) - rc = workspace_registry_commit(zdb.db); - if (rc != 0) - workspace_registry_rollback(zdb.db); - close_zova(&zdb); - return rc; -} - -static int workspace_bind_text(zova_database *db, zova_statement *stmt, int index, - const char *value, const char *phase) { - zova_statement_bind_text_request req = { - .statement = stmt, - .index = index, - .data = (const uint8_t *)value, - .len = strlen(value), - }; - return status_ok(zova_statement_bind_text(&req), db, phase); -} - -static int workspace_bind_i64(zova_database *db, zova_statement *stmt, int index, int64_t value, - const char *phase) { - zova_statement_bind_int64_request req = {.statement = stmt, .index = index, .value = value}; - return status_ok(zova_statement_bind_int64(&req), db, phase); -} - -static int workspace_step_done(zova_database *db, zova_statement *stmt, const char *phase) { - zova_step_result result = ZOVA_STEP_DONE; - zova_statement_step_request req = {.statement = stmt, .out_result = &result}; - return status_ok(zova_statement_step(&req), db, phase) == 0 && result == ZOVA_STEP_DONE ? 0 - : -1; -} - -static int workspace_key_for_id(zova_database *db, const char *workspace_id, - int64_t *out_workspace_key) { - if (!db || !workspace_id || !workspace_id[0] || !out_workspace_key) return -1; - *out_workspace_key = 0; - zova_statement *stmt = NULL; - int rc = prepare_zova(db, - "SELECT workspace_key FROM cbm_workspace_registry WHERE workspace_id=?1", - &stmt, "workspace.key_prepare"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 1, workspace_id, "workspace.key_bind"); - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0) - rc = status_ok(zova_statement_step(&(zova_statement_step_request){ - .statement = stmt, .out_result = &step}), - db, "workspace.key_step"); - if (rc == 0 && step == ZOVA_STEP_ROW) - rc = status_ok(zova_statement_column_int64(&(zova_statement_column_int64_request){ - .statement = stmt, .index = 0, .out_value = out_workspace_key}), - db, "workspace.key_column"); - else if (rc == 0) - rc = -1; - if (stmt) (void)zova_statement_finalize(stmt); - return rc == 0 && *out_workspace_key > 0 ? 0 : -1; -} - -static int next_private_key(zova_database *db, const char *sql, int64_t *out_key, - const char *phase) { - if (!db || !sql || !out_key) return -1; - zova_statement *stmt = NULL; - *out_key = 0; - int rc = prepare_zova(db, sql, &stmt, phase); - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0) - rc = status_ok(zova_statement_step(&(zova_statement_step_request){ - .statement = stmt, .out_result = &step}), - db, phase); - if (rc == 0 && step == ZOVA_STEP_ROW) - rc = status_ok(zova_statement_column_int64(&(zova_statement_column_int64_request){ - .statement = stmt, .index = 0, .out_value = out_key}), - db, phase); - else if (rc == 0) - rc = -1; - if (stmt) (void)zova_statement_finalize(stmt); - return rc == 0 && *out_key > 0 ? 0 : -1; -} - -static int file_key_get_or_create(zova_database *db, int64_t workspace_key, - const char *file_path, int64_t *out_file_key) { - if (!db || workspace_key <= 0 || !file_path || !out_file_key) return -1; - zova_statement *stmt = NULL; - int rc = prepare_zova(db, - "INSERT INTO cbm_files_v1(workspace_key,file_path) VALUES(?1,?2) " - "ON CONFLICT(workspace_key,file_path) DO NOTHING", - &stmt, "files.insert_prepare"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 1, workspace_key, "files.insert_workspace"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 2, file_path, "files.insert_path"); - if (rc == 0) rc = workspace_step_done(db, stmt, "files.insert_step"); - if (stmt) (void)zova_statement_finalize(stmt); - stmt = NULL; - if (rc == 0) - rc = prepare_zova(db, - "SELECT file_key FROM cbm_files_v1 WHERE workspace_key=?1 AND file_path=?2", - &stmt, "files.lookup_prepare"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 1, workspace_key, "files.lookup_workspace"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 2, file_path, "files.lookup_path"); - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0) - rc = status_ok(zova_statement_step(&(zova_statement_step_request){ - .statement = stmt, .out_result = &step}), - db, "files.lookup_step"); - if (rc == 0 && step == ZOVA_STEP_ROW) - rc = status_ok(zova_statement_column_int64(&(zova_statement_column_int64_request){ - .statement = stmt, .index = 0, .out_value = out_file_key}), - db, "files.lookup_column"); - else if (rc == 0) - rc = -1; - if (stmt) (void)zova_statement_finalize(stmt); - return rc == 0 && *out_file_key > 0 ? 0 : -1; -} - -static int workspace_query_state(zova_database *db, const char *workspace_id, int64_t generation, - char *out_state, size_t out_state_size) { - zova_statement *stmt = NULL; - int rc = prepare_zova(db, - "SELECT state FROM cbm_workspace_generations " - "WHERE workspace_key=(SELECT workspace_key FROM cbm_workspace_registry " - "WHERE workspace_id=?1) AND generation=?2", - &stmt, "workspace.state_prepare"); - if (rc == 0) { - rc = workspace_bind_text(db, stmt, 1, workspace_id, "workspace.state_bind_id"); - } - if (rc == 0) { - rc = workspace_bind_i64(db, stmt, 2, generation, "workspace.state_bind_generation"); - } - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0) { - zova_statement_step_request req = {.statement = stmt, .out_result = &step}; - rc = status_ok(zova_statement_step(&req), db, "workspace.state_step"); - } - if (rc == 0 && step == ZOVA_STEP_ROW) { - zova_text text = {0}; - zova_statement_column_text_request req = {.statement = stmt, .index = 0, .out_text = &text}; - rc = status_ok(zova_statement_column_text(&req), db, "workspace.state_column"); - if (rc == 0 && (!text.data || text.len + 1 > out_state_size)) { - rc = -1; - } - if (rc == 0) { - memcpy(out_state, text.data, text.len); - out_state[text.len] = '\0'; - } - zova_text_free(&text); - } else if (rc == 0) { - rc = -1; - } - if (stmt) { - (void)zova_statement_finalize(stmt); - stmt = NULL; - } - return rc; -} - -/* Stable node id -> Zova node key for one delta publish. Stable ids are hashes, - * so the key can only be found from the graph; one paged scan here replaces a - * full scan per looked-up node. Built before the delta's graph writes: nodes the - * delta re-puts are resolved from the put results first, and nodes it leaves - * alone keep their keys. */ -typedef struct { - char *node_id; - int64_t node_key; -} node_key_entry_t; - -typedef struct { - node_key_entry_t *items; - size_t count; -} node_key_index_t; - -static int node_key_entry_compare(const void *left, const void *right) { - return strcmp(((const node_key_entry_t *)left)->node_id, - ((const node_key_entry_t *)right)->node_id); -} - -static void node_key_index_free(node_key_index_t *index) { - if (!index) return; - for (size_t i = 0; i < index->count; i++) free(index->items[i].node_id); - free(index->items); - *index = (node_key_index_t){0}; -} - -static int node_key_index_build(zova_database *db, const char *workspace_id, - node_key_index_t *out) { - *out = (node_key_index_t){0}; - char graph_name[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX]; - if (cbm_zova_workspace_graph_name(workspace_id, graph_name, sizeof(graph_name)) != 0) - return -1; - size_t cap = 0; - zova_graph_scan_cursor cursor = {0}; - for (;;) { - zova_graph_scan_results page = {0}; - if (status_ok(zova_graph_scan(&(zova_graph_scan_request){ - .db = db, .graph_name = graph_name, .node_after = cursor, - .node_limit = 1024, .edge_limit = 0, .out_results = &page}), - db, "nodes.scan") != 0) goto error; - for (size_t i = 0; i < page.nodes_len; i++) { - if (!page.nodes[i].node_id) continue; - if (out->count == cap) { - size_t next_cap = cap ? cap * 2 : 1024; - node_key_entry_t *grown = realloc(out->items, next_cap * sizeof(*grown)); - if (!grown) { zova_graph_scan_results_free(&page); goto error; } - out->items = grown; - cap = next_cap; - } - char *node_id = strdup(page.nodes[i].node_id); - if (!node_id) { zova_graph_scan_results_free(&page); goto error; } - out->items[out->count++] = (node_key_entry_t){ - .node_id = node_id, .node_key = page.nodes[i].node_key}; - } - if (page.nodes_len) { - zova_graph_scan_node *last = &page.nodes[page.nodes_len - 1]; - cursor = (zova_graph_scan_cursor){.created_order = last->created_order, - .key = last->node_key}; - } - uint8_t more = page.has_more_nodes; - zova_graph_scan_results_free(&page); - if (!more) break; - } - if (out->count) qsort(out->items, out->count, sizeof(*out->items), node_key_entry_compare); - return 0; -error: - node_key_index_free(out); - return -1; -} - -static int node_key_index_find(const node_key_index_t *index, const char *node_id, - int64_t *out_node_key) { - if (!index || !node_id || !node_id[0] || !out_node_key) return -1; - *out_node_key = 0; - node_key_entry_t probe = {.node_id = (char *)node_id}; - const node_key_entry_t *entry = index->count - ? bsearch(&probe, index->items, index->count, sizeof(*index->items), - node_key_entry_compare) - : NULL; - if (!entry) return -1; - *out_node_key = entry->node_key; - return *out_node_key > 0 ? 0 : -1; -} - -static int edge_key_for_identity(zova_database *db, const char *workspace_id, - const char *source_id, const char *edge_type, - const char *target_id, int64_t *out_edge_key) { - if (!db || !workspace_id || !source_id || !edge_type || !target_id || !out_edge_key) - return -1; - *out_edge_key = 0; - char graph_name[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX]; - if (cbm_zova_workspace_graph_name(workspace_id, graph_name, sizeof(graph_name)) != 0) - return -1; - zova_graph_keyed_neighbor_results neighbors = {0}; - int rc = status_ok(zova_graph_neighbors_keyed(&(zova_graph_neighbors_keyed_request){ - .db = db, .graph_name = graph_name, .node_id = source_id, - .direction = ZOVA_GRAPH_NEIGHBOR_OUTGOING, .edge_type = edge_type, - .limit = (size_t)INT64_MAX, .out_results = &neighbors}), db, - "edges.keyed_neighbors"); - for (size_t i = 0; rc == 0 && i < neighbors.len; i++) { - if (neighbors.items[i].node_id && strcmp(neighbors.items[i].node_id, target_id) == 0) { - *out_edge_key = neighbors.items[i].edge_key; - break; - } - } - zova_graph_keyed_neighbor_results_free(&neighbors); - return rc == 0 && *out_edge_key > 0 ? 0 : -1; -} - -int cbm_zova_workspace_get_or_create_at(const char *registry_path, const char *root_path, - char *out_workspace_id, size_t out_workspace_id_size) { - char root[ZV_PATH_MAX]; - if (!registry_path || !registry_path[0] || !out_workspace_id || - out_workspace_id_size < CBM_ZOVA_WORKSPACE_ID_MAX || - workspace_normalize_root(root_path, root) != 0) { - return -1; - } - out_workspace_id[0] = '\0'; - cbm_zova_db_t zdb; - if (open_or_create_zova(registry_path, &zdb) != 0 || workspace_registry_init(zdb.db) != 0 || - workspace_registry_begin(zdb.db) != 0) { - close_zova(&zdb); - return -1; - } - zova_statement *stmt = NULL; - int rc = prepare_zova(zdb.db, - "SELECT workspace_id FROM cbm_workspace_registry WHERE canonical_root = ?1", - &stmt, "workspace.lookup_prepare"); - if (rc == 0) { - rc = workspace_bind_text(zdb.db, stmt, 1, root, "workspace.lookup_bind_root"); - } - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0) { - zova_statement_step_request req = {.statement = stmt, .out_result = &step}; - rc = status_ok(zova_statement_step(&req), zdb.db, "workspace.lookup_step"); - } - if (rc == 0 && step == ZOVA_STEP_ROW) { - zova_text text = {0}; - zova_statement_column_text_request req = {.statement = stmt, .index = 0, .out_text = &text}; - rc = status_ok(zova_statement_column_text(&req), zdb.db, "workspace.lookup_column"); - if (rc == 0 && text.data && text.len + 1 <= out_workspace_id_size) { - memcpy(out_workspace_id, text.data, text.len); - out_workspace_id[text.len] = '\0'; - } else if (rc == 0) { - rc = -1; - } - zova_text_free(&text); - } else if (rc == 0 && step != ZOVA_STEP_DONE) { - rc = -1; - } - if (stmt) { - (void)zova_statement_finalize(stmt); - stmt = NULL; - } - if (rc == 0 && out_workspace_id[0] == '\0') { - char workspace_id[CBM_ZOVA_WORKSPACE_ID_MAX]; - if (workspace_id_from_root(root, workspace_id, sizeof(workspace_id)) != 0 || - prepare_zova(zdb.db, - "INSERT INTO cbm_workspace_registry" - "(workspace_id,canonical_root,id_format_version,active_generation) " - "VALUES(?1,?2,1,0)", - &stmt, "workspace.insert_prepare") != 0 || - workspace_bind_text(zdb.db, stmt, 1, workspace_id, "workspace.insert_bind_id") != 0 || - workspace_bind_text(zdb.db, stmt, 2, root, "workspace.insert_bind_root") != 0 || - workspace_step_done(zdb.db, stmt, "workspace.insert_step") != 0) { - rc = -1; - } else { - snprintf(out_workspace_id, out_workspace_id_size, "%s", workspace_id); - } - if (stmt) { - (void)zova_statement_finalize(stmt); - } - } - if (rc == 0) { - rc = workspace_registry_commit(zdb.db); - } - if (rc != 0) { - workspace_registry_rollback(zdb.db); - out_workspace_id[0] = '\0'; - } - close_zova(&zdb); - return rc; -} - -static int zova_query_i64(zova_database *db, const char *sql, const char *bind_value, - int64_t *out_value, const char *phase); - -static int sidecar_generation_read_db(zova_database *db, int64_t *out_generation) { - if (!db || !out_generation) { - return -1; - } - *out_generation = 0; - zova_statement *stmt = NULL; - int rc = prepare_zova(db, - "SELECT MAX(generation) FROM cbm_database_generation_v1 " - "WHERE state='ready'", - &stmt, "sidecar_generation.prepare"); - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0) { - zova_statement_step_request req = {.statement = stmt, .out_result = &step}; - rc = status_ok(zova_statement_step(&req), db, "sidecar_generation.step"); - } - if (rc == 0 && step == ZOVA_STEP_ROW) { - zova_statement_column_int64_request req = { - .statement = stmt, .index = 0, .out_value = out_generation}; - rc = status_ok(zova_statement_column_int64(&req), db, "sidecar_generation.column"); - } else if (rc == 0) { - rc = -1; - } - if (stmt) { - (void)zova_statement_finalize(stmt); - } - return rc == 0 && *out_generation > 0 ? 0 : -1; -} - -static int zova_query_i64(zova_database *db, const char *sql, const char *bind_value, - int64_t *out_value, const char *phase) { - if (!db || !sql || !out_value) { - return -1; - } - *out_value = 0; - zova_statement *stmt = NULL; - int rc = prepare_zova(db, sql, &stmt, phase); - if (rc == 0 && bind_value) { - rc = workspace_bind_text(db, stmt, 1, bind_value, phase); - } - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0) { - zova_statement_step_request req = {.statement = stmt, .out_result = &step}; - rc = status_ok(zova_statement_step(&req), db, phase); - } - if (rc == 0 && step == ZOVA_STEP_ROW) { - zova_statement_column_int64_request req = { - .statement = stmt, .index = 0, .out_value = out_value}; - rc = status_ok(zova_statement_column_int64(&req), db, phase); - } else if (rc == 0) { - rc = -1; - } - if (stmt) { - (void)zova_statement_finalize(stmt); - } - return rc; -} - -/* Test-only fault points model cancellation or process loss after a durable - * source-db generation has been allocated. Unset in normal operation. */ -static bool sidecar_test_fault(const char *phase) { - const char *requested = getenv("CBM_ZOVA_TEST_FAIL_PHASE"); - return requested && phase && strcmp(requested, phase) == 0; -} - -int cbm_zova_workspace_lookup_at(const char *registry_path, const char *root_path, - char *out_workspace_id, size_t out_workspace_id_size) { - char root[ZV_PATH_MAX]; - if (!registry_path || !registry_path[0] || !out_workspace_id || - out_workspace_id_size < CBM_ZOVA_WORKSPACE_ID_MAX || - workspace_normalize_root(root_path, root) != 0 || !cbm_file_exists(registry_path)) { - return -1; - } - out_workspace_id[0] = '\0'; - cbm_zova_db_t zdb; - if (open_zova(registry_path, true, &zdb) != 0) { - return -1; - } - zova_statement *stmt = NULL; - int rc = prepare_zova(zdb.db, - "SELECT workspace_id FROM cbm_workspace_registry " - "WHERE canonical_root = ?1", - &stmt, "workspace.lookup_read_prepare"); - if (rc == 0) { - rc = workspace_bind_text(zdb.db, stmt, 1, root, "workspace.lookup_read_bind_root"); - } - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0) { - zova_statement_step_request req = {.statement = stmt, .out_result = &step}; - rc = status_ok(zova_statement_step(&req), zdb.db, "workspace.lookup_read_step"); - } - if (rc == 0 && step == ZOVA_STEP_ROW) { - zova_text text = {0}; - zova_statement_column_text_request req = {.statement = stmt, .index = 0, .out_text = &text}; - rc = status_ok(zova_statement_column_text(&req), zdb.db, "workspace.lookup_read_column"); - if (rc == 0 && text.data && text.len + 1 <= out_workspace_id_size) { - memcpy(out_workspace_id, text.data, text.len); - out_workspace_id[text.len] = '\0'; - } else if (rc == 0) { - rc = -1; - } - zova_text_free(&text); - } else if (rc == 0) { - rc = -1; - } - if (stmt) { - (void)zova_statement_finalize(stmt); - } - close_zova(&zdb); - return rc; -} - -int cbm_zova_workspace_generation_begin_at(const char *registry_path, const char *workspace_id, - int64_t generation) { - if (!registry_path || !workspace_id || !workspace_id[0] || generation <= 0) { - return -1; - } - cbm_zova_db_t zdb; - if (open_or_create_zova(registry_path, &zdb) != 0 || workspace_registry_init(zdb.db) != 0 || - workspace_legacy_generation_init(zdb.db) != 0 || workspace_registry_begin(zdb.db) != 0) { - close_zova(&zdb); - return -1; - } - zova_statement *stmt = NULL; - int rc = prepare_zova(zdb.db, - "INSERT INTO cbm_workspace_generations(workspace_key,generation,state) " - "SELECT workspace_key,?2,'building' FROM cbm_workspace_registry " - "WHERE workspace_id=?1", - &stmt, "workspace.begin_prepare"); - if (rc == 0) - rc = workspace_bind_text(zdb.db, stmt, 1, workspace_id, "workspace.begin_bind_id"); - if (rc == 0) - rc = workspace_bind_i64(zdb.db, stmt, 2, generation, "workspace.begin_bind_generation"); - if (rc == 0) - rc = workspace_step_done(zdb.db, stmt, "workspace.begin_step"); - if (stmt) - (void)zova_statement_finalize(stmt); - if (rc == 0) - rc = workspace_registry_commit(zdb.db); - if (rc != 0) - workspace_registry_rollback(zdb.db); - close_zova(&zdb); - return rc; -} - -int cbm_zova_workspace_generation_finish_at(const char *registry_path, const char *workspace_id, - int64_t generation, bool ready) { - if (!registry_path || !workspace_id || !workspace_id[0] || generation <= 0) { - return -1; - } - cbm_zova_db_t zdb; - if (open_or_create_zova(registry_path, &zdb) != 0 || workspace_registry_init(zdb.db) != 0 || - workspace_legacy_generation_init(zdb.db) != 0 || workspace_registry_begin(zdb.db) != 0) { - close_zova(&zdb); - return -1; - } - char state[16] = {0}; - int rc = workspace_query_state(zdb.db, workspace_id, generation, state, sizeof(state)); - zova_statement *stmt = NULL; - if (rc == 0 && strcmp(state, "building") != 0) { - rc = -1; - } - if (rc == 0) { - rc = prepare_zova(zdb.db, - "UPDATE cbm_workspace_generations SET state = ?1 " - "WHERE workspace_key=(SELECT workspace_key FROM cbm_workspace_registry " - "WHERE workspace_id=?2) AND generation=?3", - &stmt, "workspace.finish_prepare"); - } - if (rc == 0) - rc = workspace_bind_text(zdb.db, stmt, 1, ready ? "ready" : "failed", - "workspace.finish_bind_state"); - if (rc == 0) - rc = workspace_bind_text(zdb.db, stmt, 2, workspace_id, "workspace.finish_bind_id"); - if (rc == 0) - rc = workspace_bind_i64(zdb.db, stmt, 3, generation, "workspace.finish_bind_generation"); - if (rc == 0) - rc = workspace_step_done(zdb.db, stmt, "workspace.finish_step"); - if (stmt) - (void)zova_statement_finalize(stmt); - stmt = NULL; - if (rc == 0 && ready) { - rc = prepare_zova(zdb.db, - "UPDATE cbm_workspace_registry SET active_generation = ?1 " - "WHERE workspace_id = ?2", - &stmt, "workspace.active_prepare"); - } - if (rc == 0 && ready) - rc = workspace_bind_i64(zdb.db, stmt, 1, generation, "workspace.active_bind_generation"); - if (rc == 0 && ready) - rc = workspace_bind_text(zdb.db, stmt, 2, workspace_id, "workspace.active_bind_id"); - if (rc == 0 && ready) - rc = workspace_step_done(zdb.db, stmt, "workspace.active_step"); - if (stmt) - (void)zova_statement_finalize(stmt); - if (rc == 0) - rc = workspace_registry_commit(zdb.db); - if (rc != 0) - workspace_registry_rollback(zdb.db); - close_zova(&zdb); - return rc; -} - -int cbm_zova_workspace_active_generation_at(const char *registry_path, const char *workspace_id, - int64_t *out_generation) { - if (!registry_path || !workspace_id || !workspace_id[0] || !out_generation) { - return -1; - } - *out_generation = 0; - cbm_zova_db_t zdb; - if (open_or_create_zova(registry_path, &zdb) != 0 || workspace_registry_init(zdb.db) != 0) { - close_zova(&zdb); - return -1; - } - zova_statement *stmt = NULL; - int rc = prepare_zova(zdb.db, - "SELECT active_generation FROM cbm_workspace_registry WHERE workspace_id = ?1", - &stmt, "workspace.active_get_prepare"); - if (rc == 0) - rc = workspace_bind_text(zdb.db, stmt, 1, workspace_id, "workspace.active_get_bind_id"); - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0) { - zova_statement_step_request req = {.statement = stmt, .out_result = &step}; - rc = status_ok(zova_statement_step(&req), zdb.db, "workspace.active_get_step"); - } - if (rc == 0 && step == ZOVA_STEP_ROW) { - zova_statement_column_int64_request req = { - .statement = stmt, .index = 0, .out_value = out_generation}; - rc = status_ok(zova_statement_column_int64(&req), zdb.db, "workspace.active_get_column"); - } else if (rc == 0) { - rc = -1; - } - if (stmt) - (void)zova_statement_finalize(stmt); - close_zova(&zdb); - return rc; -} - -static int collection_create_i8(zova_database *db, const char *name, int vector_dim); - -typedef struct workspace_graph_node_map workspace_graph_node_map_t; -static const char *workspace_graph_node_map_find(const workspace_graph_node_map_t *items, - size_t count, int64_t sqlite_id); - -static int collection_delete_if_exists(zova_database *db, const char *name) { - uint8_t exists = 0; - zova_vector_collection_exists_request ereq = { - .db = db, - .name = name, - .out_exists = &exists, - }; - if (status_ok(zova_vector_collection_exists(&ereq), db, "vector.exists") != 0) { - return -1; - } - if (!exists) { - return 0; - } - zova_vector_collection_delete_request dreq = {.db = db, .name = name}; - return status_ok(zova_vector_collection_delete(&dreq), db, "vector.delete_collection"); -} - -static int collection_create_i8(zova_database *db, const char *name, int vector_dim) { - if (collection_delete_if_exists(db, name) != 0) { - return -1; - } - zova_vector_collection_create_request req = { - .db = db, - .name = name, - .options = - { - .dimensions = (uint32_t)vector_dim, - .metric = ZOVA_VECTOR_METRIC_COSINE, - .element_type = ZOVA_VECTOR_ELEMENT_TYPE_I8, - }, - }; - return status_ok(zova_vector_collection_create(&req), db, "vector.create_collection"); -} - - -typedef struct workspace_graph_node_map { - int64_t sqlite_id; - int64_t node_key; - char *stable_id; -} workspace_graph_node_map_t; - -typedef struct { - zova_graph_node_input inputs[ZV_BATCH]; - char *kinds[ZV_BATCH]; - int count; -} workspace_graph_node_batch_t; - -typedef struct { - zova_graph_edge_input inputs[ZV_BATCH]; - char *types[ZV_BATCH]; - int count; -} workspace_graph_edge_batch_t; - -static int graph_delete_named_if_exists(zova_database *db, const char *graph_name) { - uint8_t exists = 0; - zova_graph_exists_request ereq = { - .db = db, - .name = graph_name, - .out_exists = &exists, - }; - if (status_ok(zova_graph_exists(&ereq), db, "workspace_graph.exists") != 0) { - return -1; - } - if (!exists) { - return 0; - } - zova_graph_delete_request dreq = {.db = db, .name = graph_name}; - return status_ok(zova_graph_delete(&dreq), db, "workspace_graph.delete"); -} - -static void workspace_graph_node_map_free(workspace_graph_node_map_t *items, size_t count) { - if (!items) { - return; - } - for (size_t i = 0; i < count; i++) { - free(items[i].stable_id); - } - free(items); -} - -static const char *workspace_graph_node_map_find(const workspace_graph_node_map_t *items, - size_t count, int64_t sqlite_id) { - size_t low = 0; - size_t high = count; - while (low < high) { - size_t mid = low + (high - low) / 2; - if (items[mid].sqlite_id == sqlite_id) { - return items[mid].stable_id; - } - if (items[mid].sqlite_id < sqlite_id) { - low = mid + 1; - } else { - high = mid; - } - } - return NULL; -} - -static int workspace_dump_node_id_v2(const char *workspace_id, const CBMDumpNode *node, - char *out, size_t out_size) { - if (!node || !node->label || !node->name || !node->qualified_name || !node->file_path || - !workspace_name_component_valid(workspace_id) || !out || out_size == 0) { - return -1; - } - char relative_path[ZV_PATH_MAX]; - if (snprintf(relative_path, sizeof(relative_path), "%s", node->file_path) >= - (int)sizeof(relative_path)) { - return -1; - } - cbm_normalize_path_sep(relative_path); - char discriminator[512]; - if (node->qualified_name[0]) { - if (snprintf(discriminator, sizeof(discriminator), "named:%s", node->qualified_name) >= - (int)sizeof(discriminator)) return -1; - } else if (!node->name[0] || node->start_line < 0 || node->end_line < node->start_line || - snprintf(discriminator, sizeof(discriminator), "local:%s:span:%d:%d", node->name, - node->start_line, node->end_line) >= (int)sizeof(discriminator)) { - return -1; - } - return cbm_zova_workspace_node_id_v2(workspace_id, node->label, relative_path, - node->qualified_name, discriminator, out, out_size); -} - -static int workspace_graph_node_map_compare(const void *a, const void *b) { - const workspace_graph_node_map_t *left = a; - const workspace_graph_node_map_t *right = b; - if (left->sqlite_id < right->sqlite_id) return -1; - if (left->sqlite_id > right->sqlite_id) return 1; - return strcmp(left->stable_id, right->stable_id); -} - -static int workspace_generation_map_build(const char *workspace_id, const CBMDumpNode *nodes, - int node_count, workspace_graph_node_map_t **out_map, - size_t *out_count) { - if (!out_map || !out_count || node_count < 0 || (node_count > 0 && !nodes)) { - return -1; - } - *out_map = NULL; - *out_count = 0; - workspace_graph_node_map_t *map = - node_count > 0 ? calloc((size_t)node_count, sizeof(*map)) : NULL; - if (node_count > 0 && !map) { - return -1; - } - for (int i = 0; i < node_count; i++) { - char stable_id[CBM_ZOVA_WORKSPACE_ID_MAX + 64]; - if (nodes[i].id <= 0 || workspace_dump_node_id_v2(workspace_id, &nodes[i], stable_id, - sizeof(stable_id)) != 0) { - workspace_graph_node_map_free(map, (size_t)i); - return -1; - } - map[i].sqlite_id = nodes[i].id; - map[i].stable_id = zv_strndup(stable_id, strlen(stable_id)); - if (!map[i].stable_id) { - workspace_graph_node_map_free(map, (size_t)i); - return -1; - } - } - if (node_count > 1) { - qsort(map, (size_t)node_count, sizeof(*map), workspace_graph_node_map_compare); - } - for (int i = 1; i < node_count; i++) { - if (map[i - 1].sqlite_id == map[i].sqlite_id || - strcmp(map[i - 1].stable_id, map[i].stable_id) == 0) { - workspace_graph_node_map_free(map, (size_t)node_count); - return -1; - } - } - *out_map = map; - *out_count = (size_t)node_count; - return 0; -} - -static int user_database_reset_statement(zova_database *db, zova_statement *stmt, - const char *phase) { - if (!stmt || zova_statement_reset(stmt) != ZOVA_OK || - zova_statement_clear_bindings(stmt) != ZOVA_OK) { - cbm_log_error("zova.user_import", "phase", phase ? phase : "reset"); - return -1; - } - (void)db; - return 0; -} - -static int user_database_reset_rebound_statement(zova_database *db, - zova_statement *stmt, - const char *phase) { - if (!stmt || zova_statement_reset(stmt) != ZOVA_OK) { - cbm_log_error("zova.user_import", "phase", phase ? phase : "reset"); - return -1; - } - (void)db; - return 0; -} - -static int user_database_clear_workspace(zova_database *db, const char *workspace_id) { - if (user_publish_delta_active) { - user_publish_test_metrics.delta_clear_violation_count++; - return -1; - } - int64_t workspace_key = 0; - if (workspace_key_for_id(db, workspace_id, &workspace_key) != 0) return -1; - const char *statements[] = { - "DELETE FROM cbm_nodes_v1 WHERE workspace_key=?1", - ("DELETE FROM cbm_file_hashes_v1 WHERE file_key IN " - "(SELECT file_key FROM cbm_files_v1 WHERE workspace_key=?1)"), - "DELETE FROM cbm_files_v1 WHERE workspace_key=?1", - "DELETE FROM cbm_token_vector_metadata_v1 WHERE workspace_key=?1", - "DELETE FROM cbm_project_summaries_v2 WHERE workspace_key=?1", - "DELETE FROM cbm_projects_v1 WHERE workspace_key=?1", - }; - for (size_t i = 0; i < sizeof(statements) / sizeof(statements[0]); i++) { - zova_statement *stmt = NULL; - int rc = prepare_zova(db, statements[i], &stmt, "user_import.clear_prepare"); - if (rc == 0) - rc = workspace_bind_i64(db, stmt, 1, workspace_key, "user_import.clear_bind"); - if (rc == 0) { - rc = workspace_step_done(db, stmt, "user_import.clear_step"); - } - if (stmt) { - (void)zova_statement_finalize(stmt); - } - if (rc != 0) { - return -1; - } - } - return 0; -} - -static int user_database_write_nodes_with_map( - zova_database *db, const char *workspace_id, const char *project, - const CBMDumpNode *nodes, int node_count, workspace_graph_node_map_t *map, - size_t map_count) { - if (node_count < 0 || (node_count > 0 && (!nodes || !map)) || - map_count != (size_t)node_count) { - return -1; - } - zova_statement *node_stmt = NULL; - int64_t workspace_key = 0; - if (workspace_key_for_id(db, workspace_id, &workspace_key) != 0) return -1; - char graph_name[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX]; - zova_graph_node_input *native = node_count ? calloc((size_t)node_count, sizeof(*native)) : NULL; - int64_t *native_keys = node_count ? calloc((size_t)node_count, sizeof(*native_keys)) : NULL; - if (cbm_zova_workspace_graph_name(workspace_id, graph_name, sizeof(graph_name)) != 0 || - (node_count && (!native || !native_keys))) { free(native); free(native_keys); return -1; } - for (int i = 0; i < node_count; i++) native[i] = (zova_graph_node_input){ - .graph_name=graph_name,.node_id=map[i].stable_id,.kind=nodes[i].label, - .target_type=ZOVA_GRAPH_TARGET_NONE,.target_namespace=NULL, - .target_ref=NULL}; - int rc = node_count ? status_ok(zova_graph_node_put_many_keyed( - &(zova_graph_node_put_many_keyed_request){.db=db,.nodes=native, - .nodes_len=(size_t)node_count,.out_node_keys=native_keys, - .out_node_keys_capacity=(size_t)node_count}),db,"user_import.native_nodes") : 0; - free(native); - if (rc != 0) { free(native_keys); return -1; } - rc = prepare_zova( - db, - "INSERT INTO cbm_nodes_v1(zova_node_key,workspace_key,label,name,qualified_name," - "file_key,start_line,end_line,properties,source_ordinal) " - "VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10) " - "ON CONFLICT(zova_node_key) DO UPDATE SET " - "label=excluded.label,name=excluded.name,qualified_name=excluded.qualified_name," - "file_key=excluded.file_key,start_line=excluded.start_line,end_line=excluded.end_line," - "properties=excluded.properties,source_ordinal=excluded.source_ordinal", - &node_stmt, "user_import.nodes_prepare"); - for (int i = 0; rc == 0 && i < node_count; i++) { - const CBMDumpNode *node = &nodes[i]; - if (node->id <= 0 || !node->project || !node->label || !node->name || - !node->qualified_name || !node->file_path || - (i > 0 && nodes[i - 1].id >= node->id)) { - rc = -1; - break; - } - const char *stable_id = map[i].stable_id; - if (map[i].sqlite_id != node->id || !stable_id || - !workspace_name_component_valid(workspace_id)) { - rc = -1; - break; - } - - int64_t file_key = 0; - map[i].node_key = native_keys[i]; - if (file_key_get_or_create(db, workspace_key, node->file_path, &file_key) != 0) { - rc = -1; - break; - } - rc = workspace_bind_i64(db, node_stmt, 1, map[i].node_key, "user_import.node_key"); - if (rc == 0) rc = workspace_bind_i64(db, node_stmt, 2, workspace_key, - "user_import.node_workspace"); - if (rc == 0) rc = workspace_bind_text(db, node_stmt, 3, node->label, "user_import.node_label"); - if (rc == 0) rc = workspace_bind_text(db, node_stmt, 4, node->name, "user_import.node_name"); - if (rc == 0) rc = workspace_bind_text(db, node_stmt, 5, node->qualified_name, - "user_import.node_qn"); - if (rc == 0) rc = workspace_bind_i64(db, node_stmt, 6, file_key, "user_import.node_file"); - if (rc == 0) rc = workspace_bind_i64(db, node_stmt, 7, node->start_line, "user_import.node_start"); - if (rc == 0) rc = workspace_bind_i64(db, node_stmt, 8, node->end_line, "user_import.node_end"); - if (rc == 0) rc = workspace_bind_text(db, node_stmt, 9, node->properties ? node->properties : "{}", - "user_import.node_properties"); - if (rc == 0) rc = workspace_bind_i64(db, node_stmt, 10, i, - "user_import.node_source_ordinal"); - if (rc == 0) rc = workspace_step_done(db, node_stmt, "user_import.node_step"); - if (rc == 0) rc = user_database_reset_statement(db, node_stmt, "user_import.node_reset"); - } - if (node_stmt) (void)zova_statement_finalize(node_stmt); - free(native_keys); - (void)project; - return rc; -} - -static int user_database_write_nodes(zova_database *db, const char *workspace_id, - const char *project, const CBMDumpNode *nodes, int node_count, - workspace_graph_node_map_t **out_map, size_t *out_map_count) { - *out_map = NULL; - *out_map_count = 0; - workspace_graph_node_map_t *map = NULL; - size_t map_count = 0; - if (workspace_generation_map_build(workspace_id, nodes, node_count, &map, &map_count) != 0) - return -1; - if (user_database_write_nodes_with_map(db, workspace_id, project, nodes, node_count, - map, map_count) != 0) { - workspace_graph_node_map_free(map, map_count); - return -1; - } - *out_map = map; - *out_map_count = map_count; - return 0; -} - -static int user_database_write_edges(zova_database *db, const char *workspace_id, - const CBMDumpEdge *edges, int edge_count, - const workspace_graph_node_map_t *map, size_t map_count) { - if (edge_count < 0 || (edge_count > 0 && !edges)) { - return -1; - } - char graph_name[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX]; - zova_graph_edge_input *native = edge_count ? calloc((size_t)edge_count, sizeof(*native)) : NULL; - int64_t *native_keys = edge_count ? calloc((size_t)edge_count, sizeof(*native_keys)) : NULL; - if (cbm_zova_workspace_graph_name(workspace_id, graph_name, sizeof(graph_name)) != 0 || - (edge_count && (!native || !native_keys))) { free(native); free(native_keys); return -1; } - for (int i = 0; i < edge_count; i++) native[i] = (zova_graph_edge_input){ - .graph_name=graph_name,.from_node_id=workspace_graph_node_map_find(map,map_count,edges[i].source_id), - .edge_type=edges[i].type,.to_node_id=workspace_graph_node_map_find(map,map_count,edges[i].target_id)}; - int rc = edge_count ? status_ok(zova_graph_edge_put_many_keyed( - &(zova_graph_edge_put_many_keyed_request){.db=db,.edges=native, - .edges_len=(size_t)edge_count,.out_edge_keys=native_keys, - .out_edge_keys_capacity=(size_t)edge_count}),db,"user_import.native_edges") : 0; - free(native); - if (rc != 0) { free(native_keys); return -1; } - bool *handled = edge_count ? calloc((size_t)edge_count, sizeof(*handled)) : NULL; - const CBMDumpEdge **group = edge_count - ? calloc((size_t)edge_count, sizeof(*group)) : NULL; - zova_graph_edge_payload_replacement *replacements = edge_count - ? calloc((size_t)edge_count, sizeof(*replacements)) : NULL; - uint8_t **owned_payloads = edge_count - ? calloc((size_t)edge_count, sizeof(*owned_payloads)) : NULL; - if (edge_count && (!handled || !group || !replacements || !owned_payloads)) rc = -1; - size_t replacement_count = 0; - for (int i = 0; rc == 0 && i < edge_count; i++) { - if (handled[i]) continue; - if (native_keys[i] <= 0) { rc = -1; break; } - size_t group_count = 0; - for (int j = i; j < edge_count; j++) { - if (!handled[j] && native_keys[j] == native_keys[i]) { - handled[j] = true; - group[group_count++] = &edges[j]; - } - } - size_t payload_len = 0; - if (cbm_zova_edge_payload_encoded_size(group, group_count, &payload_len) != 0) { - rc = -1; - break; - } - if (payload_len > 0) { - owned_payloads[replacement_count] = malloc(payload_len); - if (!owned_payloads[replacement_count]) { rc = -1; break; } - } - size_t encoded = 0; - if (cbm_zova_edge_payload_encode( - group, group_count, owned_payloads[replacement_count], payload_len, - &encoded) != 0 || encoded != payload_len) { - rc = -1; - break; - } - replacements[replacement_count] = (zova_graph_edge_payload_replacement){ - .edge_key = native_keys[i], - .payload = owned_payloads[replacement_count], - .payload_len = payload_len, - }; - replacement_count++; - } - if (rc == 0 && replacement_count > 0) - rc = status_ok(zova_graph_edge_payload_replace_many( - &(zova_graph_edge_payload_replace_many_request){ - .db = db, .graph_name = graph_name, - .replacements = replacements, - .replacement_count = replacement_count}), - db, "user_import.edge_payloads"); - for (size_t i = 0; owned_payloads && i < replacement_count; i++) - free(owned_payloads[i]); - free(owned_payloads); - free(replacements); - free(group); - free(handled); - free(native_keys); - return rc; -} - -static int user_database_write_summary(zova_database *db, const char *workspace_id, - const cbm_zova_project_summary_input_t *summary) { - if (!summary) return 0; - if (!summary->present) { - zova_statement *stmt = NULL; - int rc = prepare_zova(db, - "DELETE FROM cbm_project_summaries_v2 WHERE workspace_key=(SELECT " - "workspace_key FROM cbm_workspace_registry WHERE workspace_id=?1)", - &stmt, "user_import.summary_delete_prepare"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 1, workspace_id, "user_import.summary_delete_bind"); - if (rc == 0) rc = workspace_step_done(db, stmt, "user_import.summary_delete_step"); - if (stmt) (void)zova_statement_finalize(stmt); - return rc; - } - if (!summary->summary || !summary->source_hash || !summary->created_at || !summary->updated_at) return -1; - zova_statement *stmt = NULL; - int rc = prepare_zova(db, - "INSERT INTO cbm_project_summaries_v2(workspace_key,summary,source_hash,created_at,updated_at) " - "SELECT workspace_key,?2,?3,?4,?5 FROM cbm_workspace_registry WHERE workspace_id=?1 " - "ON CONFLICT(workspace_key) DO UPDATE SET summary=excluded.summary," - "source_hash=excluded.source_hash,created_at=excluded.created_at,updated_at=excluded.updated_at", - &stmt, "user_import.summary_prepare"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 1, workspace_id, "user_import.summary_workspace"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 2, summary->summary, "user_import.summary_value"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 3, summary->source_hash, "user_import.summary_hash"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 4, summary->created_at, "user_import.summary_created"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 5, summary->updated_at, "user_import.summary_updated"); - if (rc == 0) rc = workspace_step_done(db, stmt, "user_import.summary_step"); - if (stmt) (void)zova_statement_finalize(stmt); - return rc; -} - -int cbm_zova_user_database_import_workspace( - const char *zova_path, const char *root_path, const char *project, int64_t generation, - const CBMDumpNode *nodes, int node_count, const CBMDumpEdge *edges, int edge_count) { - if (!zova_path || !zova_path[0] || !root_path || !root_path[0] || !project || !project[0] || - generation <= 0 || node_count < 0 || edge_count < 0 || - (node_count > 0 && !nodes) || (edge_count > 0 && !edges)) { - return -1; - } - char workspace_id[CBM_ZOVA_WORKSPACE_ID_MAX] = {0}; - if (cbm_zova_user_database_generation_begin(zova_path, root_path, generation, workspace_id, - sizeof(workspace_id)) != 0) { - return -1; - } - - cbm_zova_db_t zdb; - if (open_or_create_zova(zova_path, &zdb) != 0 || workspace_registry_begin(zdb.db) != 0) { - close_zova(&zdb); - (void)cbm_zova_user_database_generation_finish(zova_path, workspace_id, generation, false); - return -1; - } - workspace_graph_node_map_t *map = NULL; - size_t map_count = 0; - int rc = user_database_clear_workspace(zdb.db, workspace_id); - char graph_name[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX]; - if (rc == 0 && cbm_zova_workspace_graph_name(workspace_id, graph_name, - sizeof(graph_name)) != 0) rc = -1; - if (rc == 0) rc = graph_delete_named_if_exists(zdb.db, graph_name); - if (rc == 0) rc = status_ok(zova_graph_create(&(zova_graph_create_request){ - .db=zdb.db,.name=graph_name}),zdb.db,"user_import.graph_create"); - zova_statement *project_stmt = NULL; - if (rc == 0) { - rc = prepare_zova(zdb.db, - "INSERT INTO cbm_projects_v1(workspace_key,project,root_path) " - "SELECT workspace_key,?2,?3 FROM cbm_workspace_registry WHERE workspace_id=?1 " - "ON CONFLICT(workspace_key) DO UPDATE SET " - "project=excluded.project,root_path=excluded.root_path,indexed_at=CURRENT_TIMESTAMP", - &project_stmt, "user_import.project_prepare"); - } - if (rc == 0) rc = workspace_bind_text(zdb.db, project_stmt, 1, workspace_id, "user_import.project_workspace"); - if (rc == 0) rc = workspace_bind_text(zdb.db, project_stmt, 2, project, "user_import.project_name"); - if (rc == 0) rc = workspace_bind_text(zdb.db, project_stmt, 3, root_path, "user_import.project_root"); - if (rc == 0) rc = workspace_step_done(zdb.db, project_stmt, "user_import.project_step"); - if (project_stmt) (void)zova_statement_finalize(project_stmt); - if (rc == 0) { - rc = user_database_write_nodes(zdb.db, workspace_id, project, nodes, node_count, &map, - &map_count); - } - if (rc == 0 && sidecar_test_fault("user_after_nodes")) rc = -1; - if (rc == 0) rc = user_database_write_edges(zdb.db, workspace_id, edges, edge_count, map, map_count); - if (rc == 0 && sidecar_test_fault("user_after_edges")) rc = -1; - if (rc == 0 && sidecar_test_fault("user_before_commit")) rc = -1; - if (rc == 0) rc = workspace_registry_commit(zdb.db); - if (rc != 0) workspace_registry_rollback(zdb.db); - workspace_graph_node_map_free(map, map_count); - close_zova(&zdb); - if (rc != 0) { - (void)cbm_zova_user_database_generation_finish(zova_path, workspace_id, generation, false); - return -1; - } - if (cbm_zova_user_database_generation_finish(zova_path, workspace_id, generation, true) != 0) { - (void)cbm_zova_user_database_generation_finish(zova_path, workspace_id, generation, false); - return -1; - } - return 0; -} - -int cbm_zova_workspace_generation_digest_input( - const char *workspace_id, const cbm_zova_workspace_generation_input_t *input, - cbm_zova_workspace_generation_result_t *out_result) { - if (!workspace_id || !input || !out_result) return -1; - cbm_zova_publish_model_t *model = NULL; - if (cbm_zova_publish_model_build(workspace_id, input, &model) != 0) return -1; - const cbm_zova_workspace_generation_result_t *digests = - cbm_zova_publish_model_digests(model); - if (digests) *out_result = *digests; - cbm_zova_publish_model_free(model); - return digests ? 0 : -1; -} - -static int user_workspace_id_tx(zova_database *db, const char *root_path, char *out_id, size_t out_size) { - char root[ZV_PATH_MAX]; - if (workspace_normalize_root(root_path, root) != 0 || workspace_id_from_root(root, out_id, out_size) != 0) return -1; - if (workspace_registry_init(db) != 0) return -1; - zova_statement *stmt = NULL; - int rc = prepare_zova(db, - "INSERT INTO cbm_workspace_registry(workspace_id,canonical_root,id_format_version,active_generation) " - "VALUES(?1,?2,1,0) ON CONFLICT(canonical_root) DO NOTHING", - &stmt, "user_publish.workspace_prepare"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 1, out_id, "user_publish.workspace_id"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 2, root, "user_publish.workspace_root"); - if (rc == 0) rc = workspace_step_done(db, stmt, "user_publish.workspace_step"); - if (stmt) (void)zova_statement_finalize(stmt); - return rc; -} - -static int user_publish_generation_state_tx(zova_database *db, const char *workspace_id, - int64_t generation, bool ready) { - zova_statement *stmt = NULL; - int rc = prepare_zova(db, - "UPDATE cbm_database_generation_v1 SET state=?1 WHERE workspace_key=" - "(SELECT workspace_key FROM cbm_workspace_registry WHERE workspace_id=?2) " - "AND generation=?3", - &stmt, "user_publish.generation_update_prepare"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 1, ready ? "ready" : "failed", "user_publish.generation_state"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 2, workspace_id, "user_publish.generation_workspace"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 3, generation, "user_publish.generation_id"); - if (rc == 0) rc = workspace_step_done(db, stmt, "user_publish.generation_step"); - if (stmt) (void)zova_statement_finalize(stmt); - if (rc == 0 && ready) { - rc = prepare_zova(db, "UPDATE cbm_workspace_registry SET active_generation=?1 WHERE workspace_id=?2", - &stmt, "user_publish.active_prepare"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 1, generation, "user_publish.active_generation"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 2, workspace_id, "user_publish.active_workspace"); - if (rc == 0) rc = workspace_step_done(db, stmt, "user_publish.active_step"); - if (stmt) (void)zova_statement_finalize(stmt); - } - return rc; -} - -static int user_publish_clear_health_tx(zova_database *db, const char *workspace_id) { - zova_statement *statement = NULL; - int rc = prepare_zova(db, - "DELETE FROM cbm_workspace_health_v1 WHERE workspace_key=(SELECT " - "workspace_key FROM cbm_workspace_registry WHERE workspace_id=?1)", - &statement, "user_publish.health_prepare"); - if (rc == 0) - rc = workspace_bind_text(db, statement, 1, workspace_id, - "user_publish.health_workspace"); - if (rc == 0) - rc = workspace_step_done(db, statement, "user_publish.health_step"); - if (statement) (void)zova_statement_finalize(statement); - return rc; -} - -static int user_publish_insert_generation_tx(zova_database *db, const char *workspace_id, - int64_t generation) { - zova_statement *stmt = NULL; - int rc = prepare_zova( - db, - "INSERT INTO cbm_database_generation_v1(workspace_key,generation,state) " - "SELECT workspace_key,?2,'building' FROM cbm_workspace_registry WHERE workspace_id=?1", - &stmt, "user_publish.generation_insert_prepare"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 1, workspace_id, - "user_publish.generation_insert_workspace"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 2, generation, - "user_publish.generation_insert_id"); - if (rc == 0) rc = workspace_step_done(db, stmt, "user_publish.generation_insert_step"); - if (stmt) (void)zova_statement_finalize(stmt); - return rc; -} - -static int user_publish_project_tx(zova_database *db, const char *workspace_id, - const char *root_path, const char *project, - const char *indexed_at) { - zova_statement *stmt = NULL; - int rc = prepare_zova( - db, - "INSERT INTO cbm_projects_v1(workspace_key,project,root_path,indexed_at) " - "SELECT workspace_key,?2,?3,?4 FROM cbm_workspace_registry WHERE workspace_id=?1 " - "ON CONFLICT(workspace_key) DO UPDATE SET " - "project=excluded.project,root_path=excluded.root_path,indexed_at=excluded.indexed_at", - &stmt, "user_publish.project_prepare"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 1, workspace_id, - "user_publish.project_workspace"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 2, project, - "user_publish.project_name"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 3, root_path, - "user_publish.project_root"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 4, indexed_at, - "user_publish.project_indexed_at"); - if (rc == 0) rc = workspace_step_done(db, stmt, "user_publish.project_step"); - if (stmt) (void)zova_statement_finalize(stmt); - return rc; -} - -static int user_publish_write_index_state_tx(zova_database *db, const char *workspace_id, - int64_t generation, const char *model, - int dimensions, const char *indexed_at) { - zova_statement *stmt = NULL; - int rc = prepare_zova( - db, - "INSERT INTO cbm_workspace_index_state_v1" - "(workspace_key,generation,model_fingerprint,vector_dimensions,indexed_at) " - "SELECT workspace_key,?2,?3,?4,?5 FROM cbm_workspace_registry WHERE workspace_id=?1 " - "ON CONFLICT(workspace_key) DO UPDATE SET " - "generation=excluded.generation,model_fingerprint=excluded.model_fingerprint," - "vector_dimensions=excluded.vector_dimensions,indexed_at=excluded.indexed_at", - &stmt, "user_publish.index_state_prepare"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 1, workspace_id, - "user_publish.index_state_workspace"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 2, generation, - "user_publish.index_state_generation"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 3, model, - "user_publish.index_state_model"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 4, dimensions, - "user_publish.index_state_dimensions"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 5, indexed_at, - "user_publish.index_state_indexed_at"); - if (rc == 0) rc = workspace_step_done(db, stmt, "user_publish.index_state_step"); - if (stmt) (void)zova_statement_finalize(stmt); - return rc; -} - -static int user_publish_native_readback_counts_tx( - zova_database *db, const cbm_zova_workspace_generation_result_t *result, - const char *node_collection, const char *token_collection) { - if (!db || !result || !node_collection || !token_collection) { - return -1; - } - zova_vector_collection_info node_info = {0}; - zova_vector_collection_info_get_request node_request = { - .db = db, .name = node_collection, .out_info = &node_info}; - if (status_ok(zova_vector_collection_info_get(&node_request), db, - "user_publish.readback.node_collection") != 0 || - node_info.vector_count != result->node_vectors) { - zova_vector_collection_info_free(&node_info); - return -1; - } - zova_vector_collection_info_free(&node_info); - zova_vector_collection_info token_info = {0}; - zova_vector_collection_info_get_request token_request = { - .db = db, .name = token_collection, .out_info = &token_info}; - if (status_ok(zova_vector_collection_info_get(&token_request), db, - "user_publish.readback.token_collection") != 0 || - token_info.vector_count != result->token_vectors) { - zova_vector_collection_info_free(&token_info); - return -1; - } - zova_vector_collection_info_free(&token_info); - return 0; -} - -static int user_publish_integrity_tx(zova_database *db, const char *workspace_id, - int64_t generation, - const cbm_zova_workspace_generation_result_t *result) { - zova_statement *stmt = NULL; - int rc = prepare_zova( - db, - "INSERT INTO cbm_generation_integrity_v2" - "(workspace_key,generation,graph_nodes,graph_edges,metadata_nodes,metadata_edges," - "metadata_topology_edges,fts_rows,node_vector_rows,token_vector_rows,node_vectors," - "token_vectors,metadata_sha256,fts_sha256,topology_sha256,node_vector_sha256," - "token_vector_sha256) SELECT workspace_key,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12," - "?13,?14,?15,?16,?17 FROM cbm_workspace_registry WHERE workspace_id=?1", - &stmt, "user_publish.integrity_prepare"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 1, workspace_id, - "user_publish.integrity_workspace"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 2, generation, - "user_publish.integrity_generation"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 3, (int64_t)result->graph_nodes, - "user_publish.integrity_graph_nodes"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 4, (int64_t)result->graph_edges, - "user_publish.integrity_graph_edges"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 5, (int64_t)result->metadata_nodes, - "user_publish.integrity_metadata_nodes"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 6, (int64_t)result->metadata_edges, - "user_publish.integrity_metadata_edges"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 7, - (int64_t)result->metadata_topology_edges, - "user_publish.integrity_topology_edges"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 8, (int64_t)result->fts_rows, - "user_publish.integrity_fts_rows"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 9, (int64_t)result->node_vector_rows, - "user_publish.integrity_node_vector_rows"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 10, (int64_t)result->token_vector_rows, - "user_publish.integrity_token_vector_rows"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 11, (int64_t)result->node_vectors, - "user_publish.integrity_node_vectors"); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 12, (int64_t)result->token_vectors, - "user_publish.integrity_token_vectors"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 13, result->metadata_sha256, - "user_publish.integrity_metadata_digest"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 14, result->fts_sha256, - "user_publish.integrity_fts_digest"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 15, result->topology_sha256, - "user_publish.integrity_topology_digest"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 16, result->node_vector_sha256, - "user_publish.integrity_node_vector_digest"); - if (rc == 0) rc = workspace_bind_text(db, stmt, 17, result->token_vector_sha256, - "user_publish.integrity_token_vector_digest"); - if (rc == 0) rc = workspace_step_done(db, stmt, "user_publish.integrity_step"); - if (stmt) (void)zova_statement_finalize(stmt); - return rc; -} - -static double user_publish_elapsed_ms(const struct timespec *start, - const struct timespec *end) { - return ((double)(end->tv_sec - start->tv_sec) * 1000.0) + - ((double)(end->tv_nsec - start->tv_nsec) / 1000000.0); -} - -static double user_publish_profile_phase(const char *phase, struct timespec *mark) { - if (!mark) return 0.0; - struct timespec now = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &now); - double elapsed_ms = user_publish_elapsed_ms(mark, &now); - const char *enabled = getenv("CBM_ZOVA_PUBLISH_PROFILE"); - if (enabled && strcmp(enabled, "1") == 0) { - char elapsed[64]; - snprintf(elapsed, sizeof(elapsed), "%.3f", elapsed_ms); - cbm_log_info("zova.single_file_publish.phase", "phase", phase, "elapsed_ms", elapsed); - } - *mark = now; - return elapsed_ms; -} - -typedef struct { - const char *path; - int64_t file_key; -} user_file_key_t; - -typedef struct { - const char *stable_id; - int64_t node_key; -} user_node_key_t; - -typedef struct { - const char *token; - int64_t token_key; -} user_token_key_t; - -static int user_node_key_compare(const void *left_ptr, const void *right_ptr) { - const user_node_key_t *left = left_ptr; - const user_node_key_t *right = right_ptr; - return strcmp(left->stable_id, right->stable_id); -} - -static int user_token_key_compare(const void *left_ptr, const void *right_ptr) { - const user_token_key_t *left = left_ptr; - const user_token_key_t *right = right_ptr; - return strcmp(left->token, right->token); -} - -typedef struct { - const char *source_stable_id; - const char *edge_type; - const char *target_stable_id; - int64_t edge_key; -} user_topology_key_t; - -static int user_string_pointer_compare(const void *left, const void *right) { - const char *const *a = left; - const char *const *b = right; - return strcmp(*a, *b); -} - -static int64_t user_file_key_find(const user_file_key_t *items, int count, const char *path) { - int low = 0, high = count; - while (low < high) { - int mid = low + (high - low) / 2; - int order = strcmp(items[mid].path, path); - if (order == 0) return items[mid].file_key; - if (order < 0) low = mid + 1; - else high = mid; - } - return 0; -} - -static int64_t user_node_key_find(const user_node_key_t *items, int count, - const char *stable_id) { - int low = 0, high = count; - while (low < high) { - int mid = low + (high - low) / 2; - int order = strcmp(items[mid].stable_id, stable_id); - if (order == 0) return items[mid].node_key; - if (order < 0) low = mid + 1; - else high = mid; - } - return 0; -} - -static int64_t user_token_key_find(const user_token_key_t *items, int count, - const char *token) { - int low = 0, high = count; - while (low < high) { - int mid = low + (high - low) / 2; - int order = strcmp(items[mid].token, token); - if (order == 0) return items[mid].token_key; - if (order < 0) low = mid + 1; - else high = mid; - } - return 0; -} - -static int user_topology_identity_compare( - const cbm_zova_publish_edge_t *row, const char *source, - const char *edge_type, const char *target) { - int order = strcmp(row->source_stable_id, source); - if (order == 0) order = strcmp(row->source->type, edge_type); - if (order == 0) order = strcmp(row->target_stable_id, target); - return order; -} - -static const cbm_zova_publish_edge_t *user_publish_model_topology_find( - const cbm_zova_publish_model_t *model, const char *source, - const char *edge_type, const char *target) { - int low = 0; - int high = cbm_zova_publish_model_topology_count(model); - while (low < high) { - int middle = low + (high - low) / 2; - const cbm_zova_publish_edge_t *row = - cbm_zova_publish_model_topology_at(model, middle); - if (!row) return NULL; - int order = user_topology_identity_compare(row, source, edge_type, target); - if (order < 0) low = middle + 1; - else if (order > 0) high = middle; - else return row; - } - return NULL; -} - -static int user_private_key_id(int64_t key, char out[32]) { - return key > 0 && snprintf(out, 32, "%lld", (long long)key) < 32 ? 0 : -1; -} - -static int user_delta_file_keys_tx( - zova_database *db, int64_t workspace_key, - const cbm_zova_workspace_delta_t *delta, - const cbm_zova_workspace_delta_metrics_t *metrics, - user_file_key_t **out_items, int *out_count) { - uint64_t capacity64 = metrics->node_inserts + metrics->node_updates + - metrics->file_hash_upserts; - if (capacity64 > INT_MAX) return -1; - int capacity = (int)capacity64; - const char **paths = capacity ? calloc((size_t)capacity, sizeof(*paths)) : NULL; - if (capacity && !paths) return -1; - int path_count = 0; - for (uint64_t i = 0; i < metrics->node_inserts; i++) { - const cbm_zova_publish_node_t *row = - cbm_zova_workspace_delta_node_insert_at(delta, (int)i); - if (!row || !row->source) { free(paths); return -1; } - paths[path_count++] = row->source->file_path; - } - for (uint64_t i = 0; i < metrics->node_updates; i++) { - const cbm_zova_publish_node_t *row = - cbm_zova_workspace_delta_node_update_at(delta, (int)i); - if (!row || !row->source) { free(paths); return -1; } - paths[path_count++] = row->source->file_path; - } - for (uint64_t i = 0; i < metrics->file_hash_upserts; i++) { - const cbm_zova_file_hash_input_t *row = - cbm_zova_workspace_delta_file_hash_upsert_at(delta, (int)i); - if (!row) { free(paths); return -1; } - paths[path_count++] = row->file_path; - } - if (path_count > 1) { - qsort(paths, (size_t)path_count, sizeof(*paths), user_string_pointer_compare); - } - int unique_count = 0; - for (int i = 0; i < path_count; i++) - if (i == 0 || strcmp(paths[i - 1], paths[i]) != 0) - paths[unique_count++] = paths[i]; - user_file_key_t *items = unique_count ? calloc((size_t)unique_count, sizeof(*items)) : NULL; - if (unique_count && !items) { free(paths); return -1; } - int rc = 0; - for (int i = 0; rc == 0 && i < unique_count; i++) { - items[i].path = paths[i]; - rc = file_key_get_or_create(db, workspace_key, paths[i], &items[i].file_key); - if (rc == 0) user_publish_test_metrics.delta_file_key_resolutions++; - } - free(paths); - if (rc != 0) { free(items); return -1; } - *out_items = items; - *out_count = unique_count; - return 0; -} - -static int user_publish_file_keys_tx(zova_database *db, - const cbm_zova_publish_model_t *model, - int64_t workspace_key, user_file_key_t **out_items, - int *out_count) { - const cbm_zova_workspace_generation_input_t *input = cbm_zova_publish_model_input(model); - int hash_count = cbm_zova_publish_model_file_hash_count(model); - int capacity = input->node_count + hash_count; - const char **paths = capacity ? calloc((size_t)capacity, sizeof(*paths)) : NULL; - if (capacity && !paths) return -1; - int path_count = 0; - for (int i = 0; i < input->node_count; i++) paths[path_count++] = input->nodes[i].file_path; - for (int i = 0; i < hash_count; i++) - paths[path_count++] = cbm_zova_publish_model_file_hash_at(model, i)->file_path; - if (path_count > 1) { - qsort(paths, (size_t)path_count, sizeof(*paths), user_string_pointer_compare); - } - int unique_count = 0; - for (int i = 0; i < path_count; i++) - if (i == 0 || strcmp(paths[i - 1], paths[i]) != 0) paths[unique_count++] = paths[i]; - user_file_key_t *items = unique_count ? calloc((size_t)unique_count, sizeof(*items)) : NULL; - if (unique_count && !items) { free(paths); return -1; } - int64_t next_key = 0; - int rc = unique_count == 0 ? 0 : next_private_key( - db, "SELECT COALESCE(MAX(file_key),0)+1 FROM cbm_files_v1", &next_key, - "user_publish.next_file_key"); - zova_statement *statement = NULL; - if (rc == 0 && unique_count) - rc = prepare_zova(db, - "INSERT INTO cbm_files_v1(file_key,workspace_key,file_path) " - "VALUES(?1,?2,?3)", - &statement, "user_publish.files_prepare"); - for (int i = 0; rc == 0 && i < unique_count; i++) { - items[i] = (user_file_key_t){.path = paths[i], .file_key = next_key + i}; - rc = workspace_bind_i64(db, statement, 1, items[i].file_key, - "user_publish.file_key"); - if (rc == 0) rc = workspace_bind_i64(db, statement, 2, workspace_key, - "user_publish.file_workspace"); - if (rc == 0) rc = workspace_bind_text(db, statement, 3, items[i].path, - "user_publish.file_path"); - if (rc == 0) rc = workspace_step_done(db, statement, "user_publish.file_step"); - if (rc == 0) rc = user_database_reset_rebound_statement( - db, statement, "user_publish.file_reset"); - if (rc == 0) - user_publish_metric_row(&user_publish_test_metrics.canonical_files_sql, - 2, 1, 0, false); - } - if (statement) (void)zova_statement_finalize(statement); - free(paths); - if (rc != 0) { free(items); return -1; } - *out_items = items; - *out_count = unique_count; - return 0; -} - -static int user_publish_model_nodes_tx(zova_database *db, - const cbm_zova_publish_model_t *model, - int64_t workspace_key, - const user_file_key_t *file_keys, int file_key_count, - const user_node_key_t *node_keys) { - user_publish_test_metrics.canonical_node_fts_passes++; - zova_statement *node = NULL; - int count = cbm_zova_publish_model_node_count(model); - int rc = 0; - if (rc == 0) rc = prepare_zova( - db, - "INSERT INTO cbm_nodes_v1(zova_node_key,workspace_key,label,name,qualified_name," - "file_key,start_line,end_line,properties,source_ordinal) " - "VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)", - &node, "user_publish.model_nodes_prepare"); - for (int i = 0; rc == 0 && i < count; i++) { - const cbm_zova_publish_node_t *row = cbm_zova_publish_model_node_at(model, i); - const CBMDumpNode *source = row ? row->source : NULL; - int64_t file_key = source ? user_file_key_find(file_keys, file_key_count, - source->file_path) : 0; - if (!row || !source || file_key <= 0 || row->ordinal != (uint64_t)i) { - rc = -1; - break; - } - if (!node_keys || strcmp(node_keys[i].stable_id, row->stable_id) != 0 || - node_keys[i].node_key <= 0) { - rc = -1; - break; - } - rc = workspace_bind_i64(db, node, 1, node_keys[i].node_key, - "user_publish.model_node_key"); - if (rc == 0) rc = workspace_bind_i64(db, node, 2, workspace_key, - "user_publish.model_node_ws"); - if (rc == 0) rc = workspace_bind_text(db, node, 3, source->label, - "user_publish.model_node_label"); - if (rc == 0) rc = workspace_bind_text(db, node, 4, source->name, - "user_publish.model_node_name"); - if (rc == 0) rc = workspace_bind_text(db, node, 5, source->qualified_name, - "user_publish.model_node_qn"); - if (rc == 0) rc = workspace_bind_i64(db, node, 6, file_key, - "user_publish.model_node_file"); - if (rc == 0) rc = workspace_bind_i64(db, node, 7, source->start_line, - "user_publish.model_node_start"); - if (rc == 0) rc = workspace_bind_i64(db, node, 8, source->end_line, - "user_publish.model_node_end"); - if (rc == 0) rc = workspace_bind_text(db, node, 9, - source->properties ? source->properties : "{}", - "user_publish.model_node_properties"); - if (rc == 0) rc = workspace_bind_i64(db, node, 10, (int64_t)row->source_ordinal, - "user_publish.model_node_source_ordinal"); - if (rc == 0) rc = workspace_step_done(db, node, "user_publish.model_node_step"); - if (rc == 0) rc = user_database_reset_rebound_statement( - db, node, "user_publish.model_node_reset"); - if (rc == 0) - user_publish_metric_row(&user_publish_test_metrics.canonical_nodes_sql, - 6, 4, 0, false); - } - if (node) (void)zova_statement_finalize(node); - return rc; -} - -static int user_publish_model_hashes_tx(zova_database *db, - const cbm_zova_publish_model_t *model, - const user_file_key_t *file_keys, int file_key_count) { - zova_statement *statement = NULL; - int rc = prepare_zova( - db, - "INSERT INTO cbm_file_hashes_v1(file_key,content_hash,mtime_ns,size_bytes) " - "VALUES(?1,?2,?3,?4)", - &statement, "user_publish.model_hash_prepare"); - int count = cbm_zova_publish_model_file_hash_count(model); - for (int i = 0; rc == 0 && i < count; i++) { - const cbm_zova_file_hash_input_t *row = cbm_zova_publish_model_file_hash_at(model, i); - if (!row) { - rc = -1; - break; - } - int64_t file_key = user_file_key_find(file_keys, file_key_count, row->file_path); - if (file_key <= 0) { rc = -1; break; } - rc = workspace_bind_i64(db, statement, 1, file_key, - "user_publish.model_hash_file"); - if (rc == 0) rc = workspace_bind_text(db, statement, 2, row->content_hash, - "user_publish.model_hash_value"); - if (rc == 0) rc = workspace_bind_i64(db, statement, 3, row->mtime_ns, - "user_publish.model_hash_mtime"); - if (rc == 0) rc = workspace_bind_i64(db, statement, 4, row->size_bytes, - "user_publish.model_hash_size"); - if (rc == 0) rc = workspace_step_done(db, statement, - "user_publish.model_hash_step"); - if (rc == 0) rc = user_database_reset_rebound_statement( - db, statement, "user_publish.model_hash_reset"); - if (rc == 0) - user_publish_metric_row(&user_publish_test_metrics.canonical_hashes_sql, - 3, 1, 0, false); - } - if (statement) (void)zova_statement_finalize(statement); - return rc; -} - -static int user_publish_model_token_metadata_tx( - zova_database *db, const cbm_zova_publish_model_t *model, int64_t workspace_key, - user_token_key_t **out_token_keys, int *out_token_key_count) { - if (!out_token_keys || !out_token_key_count) return -1; - *out_token_keys = NULL; - *out_token_key_count = 0; - int count = cbm_zova_publish_model_token_vector_count(model); - user_token_key_t *token_keys = count ? calloc((size_t)count, sizeof(*token_keys)) : NULL; - if (count && !token_keys) return -1; - int64_t next_key = 0; - int rc = count ? next_private_key( - db, "SELECT COALESCE(MAX(token_key),0)+1 FROM cbm_token_vector_metadata_v1", - &next_key, "user_publish.next_token_key") : 0; - zova_statement *statement = NULL; - if (rc == 0 && count) rc = prepare_zova( - db, - "INSERT INTO cbm_token_vector_metadata_v1(token_key,workspace_key,token,idf) " - "VALUES(?1,?2,?3,?4)", - &statement, "user_publish.model_token_prepare"); - for (int i = 0; rc == 0 && i < count; i++) { - const cbm_zova_publish_token_vector_t *row = - cbm_zova_publish_model_token_vector_at(model, i); - if (!row || !row->source || row->ordinal != (uint64_t)i) { - rc = -1; - break; - } - token_keys[i] = (user_token_key_t){.token = row->source->token, - .token_key = next_key + i}; - rc = workspace_bind_i64(db, statement, 1, token_keys[i].token_key, - "user_publish.model_token_key"); - if (rc == 0) rc = workspace_bind_i64(db, statement, 2, workspace_key, - "user_publish.model_token_ws"); - if (rc == 0) rc = workspace_bind_text(db, statement, 3, row->source->token, - "user_publish.model_token_value"); - if (rc == 0) { - zova_statement_bind_double_request bind = { - .statement = statement, .index = 4, .value = row->source->idf}; - rc = status_ok(zova_statement_bind_double(&bind), db, - "user_publish.model_token_idf"); - } - if (rc == 0) rc = workspace_step_done(db, statement, - "user_publish.model_token_step"); - if (rc == 0) rc = user_database_reset_rebound_statement( - db, statement, "user_publish.model_token_reset"); - if (rc == 0) - user_publish_metric_row( - &user_publish_test_metrics.canonical_token_metadata_sql, - 2, 1, 1, false); - } - if (statement) (void)zova_statement_finalize(statement); - if (rc != 0) { free(token_keys); return rc; } - *out_token_keys = token_keys; - *out_token_key_count = count; - return 0; -} - -typedef struct { - double materialize_ms; - double reset_ms; - double nodes_ms; - double edges_ms; - double validate_ms; - double key_generation_ms; - double cleanup_ms; -} user_native_graph_profile_t; - -static int user_publish_model_graph_tx(zova_database *db, - const cbm_zova_publish_model_t *model, - user_node_key_t **out_node_keys, - user_topology_key_t **out_topology_keys, - uint64_t *out_nodes, uint64_t *out_edges, - user_native_graph_profile_t *profile) { - if (!profile || !out_node_keys || !out_topology_keys) return -1; - *out_node_keys = NULL; - *out_topology_keys = NULL; - memset(profile, 0, sizeof(*profile)); - struct timespec profile_mark = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &profile_mark); - const char *workspace_id = cbm_zova_publish_model_workspace_id(model); - char graph_name[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX]; - if (cbm_zova_workspace_graph_name(workspace_id, graph_name, sizeof(graph_name)) != 0) { - profile->materialize_ms = - user_publish_profile_phase("native_graph.materialize", &profile_mark); - return -1; - } - int node_count = cbm_zova_publish_model_node_count(model); - int edge_count = cbm_zova_publish_model_topology_count(model); - zova_graph_fresh_node_input *nodes = - node_count ? calloc((size_t)node_count, sizeof(*nodes)) : NULL; - zova_graph_fresh_edge_payload_input *edges = - edge_count ? calloc((size_t)edge_count, sizeof(*edges)) : NULL; - int64_t *node_key_values = node_count ? calloc((size_t)node_count, sizeof(*node_key_values)) : NULL; - int64_t *edge_key_values = edge_count ? calloc((size_t)edge_count, sizeof(*edge_key_values)) : NULL; - user_node_key_t *node_keys = node_count ? calloc((size_t)node_count, sizeof(*node_keys)) : NULL; - user_topology_key_t *topology_keys = edge_count - ? calloc((size_t)edge_count, sizeof(*topology_keys)) : NULL; - if ((node_count && (!nodes || !node_key_values || !node_keys)) || - (edge_count && (!edges || !edge_key_values || !topology_keys))) { - profile->materialize_ms = - user_publish_profile_phase("native_graph.materialize", &profile_mark); - free(nodes); - free(edges); - free(node_key_values); - free(edge_key_values); - free(node_keys); - free(topology_keys); - profile->cleanup_ms = - user_publish_profile_phase("native_graph.cleanup", &profile_mark); - return -1; - } - for (int i = 0; i < node_count; i++) { - const cbm_zova_publish_node_t *row = cbm_zova_publish_model_node_at(model, i); - nodes[i] = (zova_graph_fresh_node_input){ - .node_id = row->stable_id, .kind = row->source->label, - .target_type = ZOVA_GRAPH_TARGET_NONE, .target_namespace = NULL, - .target_ref = NULL}; - node_keys[i].stable_id = row->stable_id; - } - for (int i = 0; i < edge_count; i++) { - const cbm_zova_publish_edge_t *row = cbm_zova_publish_model_topology_at(model, i); - edges[i] = (zova_graph_fresh_edge_payload_input){ - .from_node_ordinal = (size_t)row->source_ordinal, - .edge_type = row->source->type, - .to_node_ordinal = (size_t)row->target_ordinal, - .payload = row->payload, - .payload_len = row->payload_len}; - topology_keys[i] = (user_topology_key_t){ - .source_stable_id = row->source_stable_id, - .edge_type = row->source->type, - .target_stable_id = row->target_stable_id}; - } - profile->materialize_ms = - user_publish_profile_phase("native_graph.materialize", &profile_mark); - int rc = graph_delete_named_if_exists(db, graph_name); - bool use_fresh_build = false; - zova_graph_list graph_list = {0}; - if (rc == 0) { - rc = status_ok(zova_graphs_list(&(zova_graph_list_request){ - .db = db, .out_list = &graph_list}), - db, "user_publish.model_graph_list"); - if (rc == 0) use_fresh_build = graph_list.len == 0; - } - zova_graph_list_free(&graph_list); - if (rc == 0 && !use_fresh_build) - rc = status_ok(zova_graph_create(&(zova_graph_create_request){ - .db = db, .name = graph_name}), - db, "user_publish.model_graph_create"); - profile->reset_ms = user_publish_profile_phase("native_graph.reset", &profile_mark); - if (rc == 0 && use_fresh_build) { - user_publish_test_metrics.native_graph_fresh_calls++; - user_publish_test_metrics.native_graph_prepared_calls++; - rc = status_ok(zova_graph_build_fresh_prepared_keyed_with_payloads( - &(zova_graph_build_fresh_prepared_keyed_with_payloads_request){ - .db = db, - .graph_name = graph_name, - .nodes = nodes, - .nodes_len = (size_t)node_count, - .edges = edges, - .edges_len = (size_t)edge_count, - .out_node_keys = node_key_values, - .out_node_keys_capacity = (size_t)node_count, - .out_edge_keys = edge_key_values, - .out_edge_keys_capacity = (size_t)edge_count}), - db, "user_publish.model_graph_fresh_prepared"); - if (rc == 0 && sidecar_test_fault("user_after_graph_nodes")) rc = -1; - if (rc == 0 && sidecar_test_fault("user_after_graph_edges")) rc = -1; - /* Fresh construction is one atomic Zova call, so it has no truthful - * node/edge timing boundary. Keep the node phase empty and report the - * complete bulk-build call in the edge phase used by existing reports. */ - profile->nodes_ms = 0.0; - profile->edges_ms = user_publish_profile_phase("native_graph.edges", &profile_mark); - } else if (rc == 0) { - zova_graph_node_input *legacy_nodes = - node_count ? calloc((size_t)node_count, sizeof(*legacy_nodes)) : NULL; - zova_graph_edge_input *legacy_edges = - edge_count ? calloc((size_t)edge_count, sizeof(*legacy_edges)) : NULL; - if ((node_count && !legacy_nodes) || (edge_count && !legacy_edges)) rc = -1; - for (int i = 0; rc == 0 && i < node_count; i++) { - const cbm_zova_publish_node_t *row = cbm_zova_publish_model_node_at(model, i); - legacy_nodes[i] = (zova_graph_node_input){ - .graph_name = graph_name, - .node_id = row->stable_id, - .kind = row->source->label, - .target_type = ZOVA_GRAPH_TARGET_NONE, - .target_namespace = NULL, - .target_ref = NULL}; - } - for (int i = 0; rc == 0 && i < edge_count; i++) { - const cbm_zova_publish_edge_t *row = cbm_zova_publish_model_topology_at(model, i); - legacy_edges[i] = (zova_graph_edge_input){ - .graph_name = graph_name, - .from_node_id = row->source_stable_id, - .edge_type = row->source->type, - .to_node_id = row->target_stable_id}; - } - if (rc == 0 && node_count) { - user_publish_test_metrics.native_graph_node_calls++; - rc = status_ok(zova_graph_node_put_many_keyed( - &(zova_graph_node_put_many_keyed_request){ - .db = db, - .nodes = legacy_nodes, - .nodes_len = (size_t)node_count, - .out_node_keys = node_key_values, - .out_node_keys_capacity = (size_t)node_count}), - db, "user_publish.model_graph_nodes"); - } - if (rc == 0 && sidecar_test_fault("user_after_graph_nodes")) rc = -1; - profile->nodes_ms = user_publish_profile_phase("native_graph.nodes", &profile_mark); - if (rc == 0 && edge_count) { - user_publish_test_metrics.native_graph_edge_calls++; - rc = status_ok(zova_graph_edge_put_many_keyed( - &(zova_graph_edge_put_many_keyed_request){ - .db = db, - .edges = legacy_edges, - .edges_len = (size_t)edge_count, - .out_edge_keys = edge_key_values, - .out_edge_keys_capacity = (size_t)edge_count}), - db, "user_publish.model_graph_edges"); - } - if (rc == 0 && edge_count) { - zova_graph_edge_payload_replacement *replacements = - calloc((size_t)edge_count, sizeof(*replacements)); - if (!replacements) rc = -1; - for (int i = 0; rc == 0 && i < edge_count; i++) { - const cbm_zova_publish_edge_t *row = - cbm_zova_publish_model_topology_at(model, i); - replacements[i] = (zova_graph_edge_payload_replacement){ - .edge_key = edge_key_values[i], - .payload = row->payload, - .payload_len = row->payload_len, - }; - } - if (rc == 0) - rc = status_ok(zova_graph_edge_payload_replace_many( - &(zova_graph_edge_payload_replace_many_request){ - .db = db, - .graph_name = graph_name, - .replacements = replacements, - .replacement_count = (size_t)edge_count}), - db, "user_publish.model_graph_payloads"); - free(replacements); - } - if (rc == 0 && sidecar_test_fault("user_after_graph_edges")) rc = -1; - profile->edges_ms = user_publish_profile_phase("native_graph.edges", &profile_mark); - free(legacy_nodes); - free(legacy_edges); - } - for (int i = 0; rc == 0 && i < node_count; i++) { - if (node_key_values[i] <= 0) rc = -1; - node_keys[i].node_key = node_key_values[i]; - } - for (int i = 0; rc == 0 && i < edge_count; i++) { - if (edge_key_values[i] <= 0) rc = -1; - topology_keys[i].edge_key = edge_key_values[i]; - } - zova_graph_info info = {0}; - if (rc == 0) { - rc = status_ok(zova_graph_info_get(&(zova_graph_info_get_request){ - .db = db, .name = graph_name, .out_info = &info}), - db, "user_publish.model_graph_info"); - if (rc == 0) { - if (out_nodes) *out_nodes = info.node_count; - if (out_edges) *out_edges = info.edge_count; - } - profile->validate_ms = - user_publish_profile_phase("native_graph.validate", &profile_mark); - } - zova_graph_info_free(&info); - free(nodes); - free(edges); - free(node_key_values); - free(edge_key_values); - if (rc == 0) { - *out_node_keys = node_keys; - *out_topology_keys = topology_keys; - } else { - free(node_keys); - free(topology_keys); - } - profile->cleanup_ms = user_publish_profile_phase("native_graph.cleanup", &profile_mark); - return rc; -} - -static int user_publish_model_vectors_tx(zova_database *db, - const cbm_zova_publish_model_t *model, - const char *node_collection, - const char *token_collection, - const user_node_key_t *node_keys, int node_key_count, - const user_token_key_t *token_keys, - int token_key_count) { - const cbm_zova_workspace_generation_input_t *input = cbm_zova_publish_model_input(model); - int node_count = cbm_zova_publish_model_node_vector_count(model); - int token_count = cbm_zova_publish_model_token_vector_count(model); - zova_vector_input *nodes = node_count ? calloc((size_t)node_count, sizeof(*nodes)) : NULL; - zova_vector_input *tokens = token_count ? calloc((size_t)token_count, sizeof(*tokens)) : NULL; - char (*node_ids)[32] = node_count ? calloc((size_t)node_count, sizeof(*node_ids)) : NULL; - char (*token_ids)[32] = token_count ? calloc((size_t)token_count, sizeof(*token_ids)) : NULL; - if ((node_count && (!nodes || !node_ids)) || (token_count && (!tokens || !token_ids))) { - free(nodes); - free(tokens); - free(node_ids); - free(token_ids); - return -1; - } - for (int i = 0; i < node_count; i++) { - const cbm_zova_publish_node_vector_t *row = - cbm_zova_publish_model_node_vector_at(model, i); - int64_t key = row && row->node_ordinal < (uint64_t)node_key_count - ? node_keys[row->node_ordinal].node_key - : 0; - if (!row || row->ordinal != (uint64_t)i || key <= 0 || - strcmp(node_keys[row->node_ordinal].stable_id, row->stable_id) != 0 || - user_private_key_id(key, node_ids[i]) != 0) { - free(nodes); free(tokens); free(node_ids); free(token_ids); return -1; - } - nodes[i] = (zova_vector_input){ - .id = node_ids[i], - .values = {.element_type = ZOVA_VECTOR_ELEMENT_TYPE_I8, - .i8_values = (const int8_t *)row->source->vector, - .values_len = (size_t)input->vector_dimensions}}; - } - for (int i = 0; i < token_count; i++) { - const cbm_zova_publish_token_vector_t *row = - cbm_zova_publish_model_token_vector_at(model, i); - int64_t key = row && i < token_key_count ? token_keys[i].token_key : 0; - if (!row || !row->source || row->ordinal != (uint64_t)i || key <= 0 || - strcmp(token_keys[i].token, row->source->token) != 0 || - user_private_key_id(key, token_ids[i]) != 0) { - free(nodes); free(tokens); free(node_ids); free(token_ids); return -1; - } - tokens[i] = (zova_vector_input){ - .id = token_ids[i], - .values = {.element_type = ZOVA_VECTOR_ELEMENT_TYPE_I8, - .i8_values = (const int8_t *)row->source->vector, - .values_len = (size_t)input->vector_dimensions}}; - } - int rc = collection_create_i8(db, node_collection, input->vector_dimensions); - if (rc == 0) rc = collection_create_i8(db, token_collection, input->vector_dimensions); - if (rc == 0 && node_count) - user_publish_test_metrics.native_node_vector_calls++; - if (rc == 0 && node_count) - rc = status_ok(zova_vector_put_many(&(zova_vector_put_many_request){ - .db = db, .collection_name = node_collection, - .vectors = nodes, .vectors_len = (size_t)node_count}), - db, "user_publish.model_node_vectors"); - if (rc == 0 && sidecar_test_fault("user_after_node_vectors")) rc = -1; - if (rc == 0 && token_count) - user_publish_test_metrics.native_token_vector_calls++; - if (rc == 0 && token_count) - rc = status_ok(zova_vector_put_many(&(zova_vector_put_many_request){ - .db = db, .collection_name = token_collection, - .vectors = tokens, .vectors_len = (size_t)token_count}), - db, "user_publish.model_token_vectors"); - if (rc == 0 && sidecar_test_fault("user_after_token_vectors")) rc = -1; - free(nodes); - free(tokens); - free(node_ids); - free(token_ids); - return rc; -} - -static int user_delta_delete_ids_tx(zova_database *db, int64_t workspace_key, - const char *sql, const char *phase, - const cbm_zova_workspace_delta_t *delta, - const char *(*item_at)(const cbm_zova_workspace_delta_t *, int), - uint64_t count) { - zova_statement *statement = NULL; - int rc = count ? prepare_zova(db, sql, &statement, phase) : 0; - for (uint64_t i = 0; rc == 0 && i < count; i++) { - const char *id = item_at(delta, (int)i); - if (!id) { rc = -1; break; } - rc = workspace_bind_i64(db, statement, 1, workspace_key, phase); - if (rc == 0) rc = workspace_bind_text(db, statement, 2, id, phase); - if (rc == 0) rc = workspace_step_done(db, statement, phase); - if (rc == 0) rc = user_database_reset_statement(db, statement, phase); - } - if (statement) (void)zova_statement_finalize(statement); - return rc; -} - -static int user_delta_delete_native_tx(zova_database *db, const char *workspace_id, - const node_key_index_t *node_index, - const char *node_collection, - const char *token_collection, - const cbm_zova_workspace_delta_t *delta, - const cbm_zova_workspace_delta_metrics_t *metrics) { - const char **node_vectors = metrics->node_vector_deletes - ? calloc((size_t)metrics->node_vector_deletes, sizeof(*node_vectors)) : NULL; - const char **token_vectors = metrics->token_vector_deletes - ? calloc((size_t)metrics->token_vector_deletes, sizeof(*token_vectors)) : NULL; - char (*node_vector_ids)[32] = metrics->node_vector_deletes - ? calloc((size_t)metrics->node_vector_deletes, sizeof(*node_vector_ids)) : NULL; - char (*token_vector_ids)[32] = metrics->token_vector_deletes - ? calloc((size_t)metrics->token_vector_deletes, sizeof(*token_vector_ids)) : NULL; - if ((metrics->node_vector_deletes && !node_vectors) || - (metrics->token_vector_deletes && !token_vectors) || - (metrics->node_vector_deletes && !node_vector_ids) || - (metrics->token_vector_deletes && !token_vector_ids)) { - free(node_vectors); free(token_vectors); free(node_vector_ids); free(token_vector_ids); - return -1; - } - int64_t workspace_key = 0; - int rc = workspace_key_for_id(db, workspace_id, &workspace_key); - for (uint64_t i = 0; rc == 0 && i < metrics->node_vector_deletes; i++) { - int64_t node_key = 0; - const char *stable_id = cbm_zova_workspace_delta_node_vector_delete_at(delta, (int)i); - if (!stable_id || node_key_index_find(node_index, stable_id, &node_key) != 0 || - user_private_key_id(node_key, node_vector_ids[i]) != 0) rc = -1; - node_vectors[i] = node_vector_ids[i]; - } - zova_statement *token_key_statement = NULL; - if (rc == 0 && metrics->token_vector_deletes) - rc = prepare_zova(db, "SELECT token_key FROM cbm_token_vector_metadata_v1 " - "WHERE workspace_key=?1 AND token=?2", &token_key_statement, - "user_delta.token_key_prepare"); - for (uint64_t i = 0; rc == 0 && i < metrics->token_vector_deletes; i++) { - const char *token = cbm_zova_workspace_delta_token_vector_delete_at(delta, (int)i); - if (zova_statement_reset(token_key_statement) != ZOVA_OK || - zova_statement_clear_bindings(token_key_statement) != ZOVA_OK || - workspace_bind_i64(db, token_key_statement, 1, workspace_key, - "user_delta.token_key_ws") != 0 || - workspace_bind_text(db, token_key_statement, 2, token, - "user_delta.token_key_token") != 0) { rc = -1; break; } - zova_step_result step = ZOVA_STEP_DONE; - int64_t token_key = 0; - if (status_ok(zova_statement_step(&(zova_statement_step_request){ - .statement=token_key_statement,.out_result=&step}),db, - "user_delta.token_key_step") != 0 || step != ZOVA_STEP_ROW || - status_ok(zova_statement_column_int64(&(zova_statement_column_int64_request){ - .statement=token_key_statement,.index=0,.out_value=&token_key}),db, - "user_delta.token_key_value") != 0 || - user_private_key_id(token_key, token_vector_ids[i]) != 0) { rc = -1; break; } - token_vectors[i] = token_vector_ids[i]; - } - if (token_key_statement) (void)zova_statement_finalize(token_key_statement); - if (rc == 0 && metrics->node_vector_deletes) - rc = status_ok(zova_vector_delete_many(&(zova_vector_delete_many_request){ - .db=db,.collection_name=node_collection,.vector_ids=node_vectors, - .vector_count=(size_t)metrics->node_vector_deletes}), - db,"user_delta.node_vector_delete"); - if (rc == 0 && metrics->token_vector_deletes) - rc = status_ok(zova_vector_delete_many(&(zova_vector_delete_many_request){ - .db=db,.collection_name=token_collection,.vector_ids=token_vectors, - .vector_count=(size_t)metrics->token_vector_deletes}), - db,"user_delta.token_vector_delete"); - if (rc == 0 && metrics->token_vector_deletes) - rc = user_delta_delete_ids_tx(db, workspace_key, - "DELETE FROM cbm_token_vector_metadata_v1 WHERE workspace_key=?1 AND token=?2", - "user_delta.token_delete", delta, - cbm_zova_workspace_delta_token_vector_delete_at, - metrics->token_vector_deletes); - free(node_vectors); free(token_vectors); free(node_vector_ids); free(token_vector_ids); - if (rc == 0 && sidecar_test_fault("user_delta_after_vector_deletes")) rc = -1; - if (rc != 0) return rc; - - char graph_name[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX]; - if (cbm_zova_workspace_graph_name(workspace_id, graph_name, sizeof(graph_name)) != 0) - return -1; - zova_graph_edge_input *edges = metrics->topology_deletes - ? calloc((size_t)metrics->topology_deletes, sizeof(*edges)) : NULL; - const char **nodes = metrics->node_deletes - ? calloc((size_t)metrics->node_deletes, sizeof(*nodes)) : NULL; - if ((metrics->topology_deletes && !edges) || (metrics->node_deletes && !nodes)) { - free(edges); free(nodes); return -1; - } - for (uint64_t i = 0; rc == 0 && i < metrics->topology_deletes; i++) { - const cbm_zova_delta_topology_edge_t *edge = - cbm_zova_workspace_delta_topology_delete_at(delta, (int)i); - if (!edge) { rc = -1; break; } - edges[i] = (zova_graph_edge_input){.graph_name=graph_name, - .from_node_id=edge->source_stable_id,.edge_type=edge->edge_type, - .to_node_id=edge->target_stable_id}; - } - for (uint64_t i = 0; i < metrics->node_deletes; i++) - nodes[i] = cbm_zova_workspace_delta_node_delete_at(delta, (int)i); - if (metrics->topology_deletes) - rc = status_ok(zova_graph_edge_delete_many(&(zova_graph_edge_delete_many_request){ - .db=db,.edges=edges,.edges_len=(size_t)metrics->topology_deletes}), - db,"user_delta.graph_edge_delete"); - if (rc == 0 && metrics->node_deletes) - rc = status_ok(zova_graph_node_delete_many(&(zova_graph_node_delete_many_request){ - .db=db,.graph_name=graph_name,.node_ids=nodes, - .node_count=(size_t)metrics->node_deletes}), - db,"user_delta.graph_node_delete"); - free(edges); free(nodes); - if (rc == 0 && sidecar_test_fault("user_delta_after_graph_deletes")) rc = -1; - return rc; -} - -static int user_delta_delete_canonical_tx( - zova_database *db, int64_t workspace_key, const node_key_index_t *node_index, - const cbm_zova_workspace_delta_t *delta, - const cbm_zova_workspace_delta_metrics_t *metrics) { - int rc = 0; - zova_statement *node_delete = NULL; - if (rc == 0 && metrics->node_deletes) rc = prepare_zova( - db, "DELETE FROM cbm_nodes_v1 WHERE workspace_key=?1 AND zova_node_key=?2", - &node_delete, "user_delta.node_delete_prepare"); - for (uint64_t i = 0; rc == 0 && i < metrics->node_deletes; i++) { - const char *stable_id = cbm_zova_workspace_delta_node_delete_at(delta, (int)i); - int64_t node_key = 0; - if (!stable_id || node_key_index_find(node_index, stable_id, &node_key) != 0) { - rc = -1; break; - } - rc = workspace_bind_i64(db, node_delete, 1, workspace_key, - "user_delta.node_delete_ws"); - if (rc == 0) rc = workspace_bind_i64(db, node_delete, 2, node_key, - "user_delta.node_delete_key"); - if (rc == 0) rc = workspace_step_done(db, node_delete, - "user_delta.node_delete_step"); - if (rc == 0) rc = user_database_reset_statement(db, node_delete, - "user_delta.node_delete_reset"); - } - if (node_delete) (void)zova_statement_finalize(node_delete); - if (rc == 0) rc = user_delta_delete_ids_tx(db, workspace_key, - "DELETE FROM cbm_file_hashes_v1 WHERE file_key=(SELECT file_key FROM cbm_files_v1 " - "WHERE workspace_key=?1 AND file_path=?2)", - "user_delta.hash_delete", delta, cbm_zova_workspace_delta_file_hash_delete_at, - metrics->file_hash_deletes); - if (rc == 0 && sidecar_test_fault("user_delta_after_canonical_deletes")) rc = -1; - return rc; -} - -static int user_delta_upsert_nodes_tx(zova_database *db, int64_t workspace_key, - const cbm_zova_publish_model_t *model, - const cbm_zova_workspace_delta_t *delta, - const cbm_zova_workspace_delta_metrics_t *metrics, - const user_file_key_t *file_keys, - int file_key_count, - const user_node_key_t *node_keys, - int node_key_count) { - (void)model; - zova_statement *node = NULL; - int total = (int)(metrics->node_inserts + metrics->node_updates); - int rc = 0; - if (rc == 0 && total) rc = prepare_zova(db, - "INSERT INTO cbm_nodes_v1(zova_node_key,workspace_key,label,name,qualified_name,file_key," - "start_line,end_line,properties,source_ordinal) " - "VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10) " - "ON CONFLICT(zova_node_key) DO UPDATE SET " - "label=excluded.label,name=excluded.name,qualified_name=excluded.qualified_name," - "file_key=excluded.file_key,start_line=excluded.start_line,end_line=excluded.end_line," - "properties=excluded.properties,source_ordinal=excluded.source_ordinal", - &node,"user_delta.node_upsert_prepare"); - for (int i = 0; rc == 0 && i < total; i++) { - const cbm_zova_publish_node_t *row = i < (int)metrics->node_inserts - ? cbm_zova_workspace_delta_node_insert_at(delta, i) - : cbm_zova_workspace_delta_node_update_at(delta, i - (int)metrics->node_inserts); - const CBMDumpNode *source = row->source; - int64_t node_key = user_node_key_find(node_keys, node_key_count, row->stable_id); - int64_t file_key = user_file_key_find(file_keys, file_key_count, - source->file_path); - if (rc == 0 && (node_key <= 0 || file_key <= 0)) rc = -1; - if (rc==0) rc=workspace_bind_i64(db,node,1,node_key,"user_delta.node_key"); - if (rc==0) rc=workspace_bind_i64(db,node,2,workspace_key,"user_delta.node_ws"); - if (rc==0) rc=workspace_bind_text(db,node,3,source->label,"user_delta.node_label"); - if (rc==0) rc=workspace_bind_text(db,node,4,source->name,"user_delta.node_name"); - if (rc==0) rc=workspace_bind_text(db,node,5,source->qualified_name,"user_delta.node_qn"); - if (rc==0) rc=workspace_bind_i64(db,node,6,file_key,"user_delta.node_file"); - if (rc==0) rc=workspace_bind_i64(db,node,7,source->start_line,"user_delta.node_start"); - if (rc==0) rc=workspace_bind_i64(db,node,8,source->end_line,"user_delta.node_end"); - if (rc==0) rc=workspace_bind_text(db,node,9,source->properties?source->properties:"{}","user_delta.node_props"); - if (rc==0) rc=workspace_bind_i64(db,node,10,(int64_t)row->source_ordinal, - "user_delta.node_source_ordinal"); - if (rc==0) rc=workspace_step_done(db,node,"user_delta.node_step"); - if (rc==0) rc=user_database_reset_statement(db,node,"user_delta.node_reset"); - } - if (node) (void)zova_statement_finalize(node); - return rc; -} - -static int user_delta_token_keys_tx(zova_database *db, int64_t workspace_key, - const cbm_zova_workspace_delta_t *delta, - const cbm_zova_workspace_delta_metrics_t *metrics, - user_token_key_t **out_keys, int *out_count) { - if (!out_keys || !out_count) return -1; - *out_keys = NULL; - *out_count = 0; - int count = (int)metrics->token_vector_upserts; - if (count == 0) return 0; - user_token_key_t *keys = calloc((size_t)count, sizeof(*keys)); - if (!keys) return -1; - int64_t next_key = 0; - int rc = next_private_key( - db, "SELECT COALESCE(MAX(token_key),0)+1 FROM cbm_token_vector_metadata_v1", - &next_key, "user_delta.next_token_key"); - zova_statement *statement = NULL; - if (rc == 0) rc = prepare_zova(db, - "SELECT token_key FROM cbm_token_vector_metadata_v1 " - "WHERE workspace_key=?1 AND token=?2", &statement, - "user_delta.token_lookup_prepare"); - int64_t allocated = 0; - for (int i = 0; rc == 0 && i < count; i++) { - const cbm_zova_publish_token_vector_t *row = - cbm_zova_workspace_delta_token_vector_upsert_at(delta, i); - if (!row || !row->source || !row->source->token) { rc = -1; break; } - if (zova_statement_reset(statement) != ZOVA_OK || - zova_statement_clear_bindings(statement) != ZOVA_OK || - workspace_bind_i64(db, statement, 1, workspace_key, - "user_delta.token_lookup_ws") != 0 || - workspace_bind_text(db, statement, 2, row->source->token, - "user_delta.token_lookup_token") != 0) { rc = -1; break; } - zova_step_result step = ZOVA_STEP_DONE; - if (status_ok(zova_statement_step(&(zova_statement_step_request){ - .statement=statement,.out_result=&step}),db, - "user_delta.token_lookup_step") != 0) { rc = -1; break; } - int64_t key = 0; - if (step == ZOVA_STEP_ROW) { - if (status_ok(zova_statement_column_int64(&(zova_statement_column_int64_request){ - .statement=statement,.index=0,.out_value=&key}),db, - "user_delta.token_lookup_value") != 0) rc = -1; - } else if (step == ZOVA_STEP_DONE) { - key = next_key + allocated++; - } else rc = -1; - if (key <= 0) rc = -1; - keys[i] = (user_token_key_t){.token=row->source->token,.token_key=key}; - } - if (statement) (void)zova_statement_finalize(statement); - if (rc != 0) { free(keys); return rc; } - if (count > 1) qsort(keys, (size_t)count, sizeof(*keys), user_token_key_compare); - *out_keys = keys; - *out_count = count; - return 0; -} - -static int user_delta_put_native_tx(zova_database *db, const char *workspace_id, - int64_t workspace_key, - const node_key_index_t *node_index, - const char *node_collection,const char *token_collection, - const cbm_zova_workspace_delta_t *delta, - const cbm_zova_workspace_delta_metrics_t *metrics, - const cbm_zova_publish_model_t *model, - user_node_key_t **out_node_keys, - int *out_node_key_count, - int64_t **out_edge_keys, - const user_token_key_t *token_keys, - int token_key_count) { - char graph_name[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX]; - if(cbm_zova_workspace_graph_name(workspace_id,graph_name,sizeof(graph_name))!=0)return -1; - uint64_t node_count=metrics->node_inserts+metrics->node_updates; - zova_graph_node_input *nodes=node_count?calloc((size_t)node_count,sizeof(*nodes)):NULL; - zova_graph_edge_input *edges=metrics->edge_inserts?calloc((size_t)metrics->edge_inserts,sizeof(*edges)):NULL; - int64_t *node_key_values=node_count?calloc((size_t)node_count,sizeof(*node_key_values)):NULL; - user_node_key_t *node_keys=node_count?calloc((size_t)node_count,sizeof(*node_keys)):NULL; - int64_t *edge_keys=metrics->edge_inserts?calloc((size_t)metrics->edge_inserts,sizeof(*edge_keys)):NULL; - if((node_count&&(!nodes||!node_key_values||!node_keys))|| - (metrics->edge_inserts&&(!edges||!edge_keys))){free(nodes);free(edges);free(node_key_values);free(node_keys);free(edge_keys);return -1;} - for(uint64_t i=0;inode_inserts? - cbm_zova_workspace_delta_node_insert_at(delta,(int)i): - cbm_zova_workspace_delta_node_update_at(delta,(int)(i-metrics->node_inserts)); - nodes[i]=(zova_graph_node_input){.graph_name=graph_name,.node_id=row->stable_id, - .kind=row->source->label,.target_type=ZOVA_GRAPH_TARGET_NONE, - .target_namespace=NULL,.target_ref=NULL}; - node_keys[i].stable_id=row->stable_id; - } - for(uint64_t i=0;iedge_inserts;i++){ - const cbm_zova_publish_edge_t *row=cbm_zova_workspace_delta_edge_insert_at(delta,(int)i); - edges[i]=(zova_graph_edge_input){.graph_name=graph_name,.from_node_id=row->source_stable_id, - .edge_type=row->source->type,.to_node_id=row->target_stable_id}; - } - int rc=node_count?status_ok(zova_graph_node_put_many_keyed(&(zova_graph_node_put_many_keyed_request){ - .db=db,.nodes=nodes,.nodes_len=(size_t)node_count,.out_node_keys=node_key_values, - .out_node_keys_capacity=(size_t)node_count}),db,"user_delta.graph_nodes"):0; - for(uint64_t i=0;rc==0&&i1)qsort(node_keys,(size_t)node_count,sizeof(*node_keys),user_node_key_compare); - if(rc==0&&metrics->edge_inserts)rc=status_ok(zova_graph_edge_put_many_keyed( - &(zova_graph_edge_put_many_keyed_request){.db=db,.edges=edges, - .edges_len=(size_t)metrics->edge_inserts,.out_edge_keys=edge_keys, - .out_edge_keys_capacity=(size_t)metrics->edge_inserts}),db,"user_delta.graph_edges"); - for(uint64_t i=0;rc==0&&iedge_inserts;i++)if(edge_keys[i]<=0)rc=-1; - int topology_count = cbm_zova_publish_model_topology_count(model); - bool *affected = topology_count - ? calloc((size_t)topology_count, sizeof(*affected)) : NULL; - zova_graph_edge_payload_replacement *replacements = topology_count - ? calloc((size_t)topology_count, sizeof(*replacements)) : NULL; - if (topology_count && (!affected || !replacements)) rc = -1; - for (uint64_t i = 0; rc == 0 && i < metrics->edge_inserts; i++) { - const cbm_zova_publish_edge_t *row = - cbm_zova_workspace_delta_edge_insert_at(delta, (int)i); - if (!row || row->topology_ordinal >= (uint64_t)topology_count) rc = -1; - else affected[row->topology_ordinal] = true; - } - for (uint64_t i = 0; rc == 0 && i < metrics->edge_deletes; i++) { - const cbm_zova_delta_edge_delete_t *deleted = - cbm_zova_workspace_delta_edge_delete_at(delta, (int)i); - if (!deleted) { rc = -1; break; } - const cbm_zova_publish_edge_t *row = user_publish_model_topology_find( - model, deleted->source_stable_id, deleted->edge_type, - deleted->target_stable_id); - if (row) { - if (row->ordinal >= (uint64_t)topology_count) rc = -1; - else affected[row->ordinal] = true; - } - } - size_t replacement_count = 0; - for (int i = 0; rc == 0 && i < topology_count; i++) { - if (!affected[i]) continue; - const cbm_zova_publish_edge_t *row = - cbm_zova_publish_model_topology_at(model, i); - int64_t edge_key = 0; - if (!row || edge_key_for_identity(db, workspace_id, - row->source_stable_id, row->source->type, - row->target_stable_id, &edge_key) != 0 || edge_key <= 0) { - rc = -1; - break; - } - replacements[replacement_count++] = (zova_graph_edge_payload_replacement){ - .edge_key = edge_key, - .payload = row->payload, - .payload_len = row->payload_len, - }; - } - if (rc == 0 && replacement_count > 0) - rc = status_ok(zova_graph_edge_payload_replace_many( - &(zova_graph_edge_payload_replace_many_request){ - .db = db, .graph_name = graph_name, - .replacements = replacements, - .replacement_count = replacement_count}), - db, "user_delta.graph_payloads"); - free(affected); - free(replacements); - free(nodes);free(edges);free(node_key_values); - if(rc==0&&sidecar_test_fault("user_delta_after_graph_puts"))rc=-1; - if(rc!=0){free(node_keys);free(edge_keys);return rc;} - - const cbm_zova_workspace_generation_input_t *input=cbm_zova_publish_model_input(model); - zova_vector_input *node_vectors=metrics->node_vector_upserts?calloc((size_t)metrics->node_vector_upserts,sizeof(*node_vectors)):NULL; - zova_vector_input *token_vectors=metrics->token_vector_upserts?calloc((size_t)metrics->token_vector_upserts,sizeof(*token_vectors)):NULL; - char (*node_vector_ids)[32]=metrics->node_vector_upserts?calloc((size_t)metrics->node_vector_upserts,sizeof(*node_vector_ids)):NULL; - char (*token_vector_ids)[32]=metrics->token_vector_upserts?calloc((size_t)metrics->token_vector_upserts,sizeof(*token_vector_ids)):NULL; - if((metrics->node_vector_upserts&&(!node_vectors||!node_vector_ids))|| - (metrics->token_vector_upserts&&(!token_vectors||!token_vector_ids))){ - free(node_vectors);free(token_vectors);free(node_vector_ids);free(token_vector_ids); - free(node_keys);free(edge_keys);return -1; - } - for(uint64_t i=0;inode_vector_upserts;i++){ - const cbm_zova_publish_node_vector_t *row=cbm_zova_workspace_delta_node_vector_upsert_at(delta,(int)i); - int64_t key=user_node_key_find(node_keys,(int)node_count,row->stable_id); - if(key<=0&&node_key_index_find(node_index,row->stable_id,&key)!=0)rc=-1; - if(rc==0&&user_private_key_id(key,node_vector_ids[i])!=0)rc=-1; - node_vectors[i]=(zova_vector_input){.id=node_vector_ids[i],.values={.element_type=ZOVA_VECTOR_ELEMENT_TYPE_I8, - .i8_values=(const int8_t*)row->source->vector,.values_len=(size_t)input->vector_dimensions}}; - } - for(uint64_t i=0;itoken_vector_upserts;i++){ - const cbm_zova_publish_token_vector_t *row=cbm_zova_workspace_delta_token_vector_upsert_at(delta,(int)i); - int64_t key=user_token_key_find(token_keys,token_key_count,row->source->token); - if(user_private_key_id(key,token_vector_ids[i])!=0)rc=-1; - token_vectors[i]=(zova_vector_input){.id=token_vector_ids[i],.values={.element_type=ZOVA_VECTOR_ELEMENT_TYPE_I8, - .i8_values=(const int8_t*)row->source->vector,.values_len=(size_t)input->vector_dimensions}}; - } - if(rc==0&&metrics->node_vector_upserts)rc=status_ok(zova_vector_put_many(&(zova_vector_put_many_request){ - .db=db,.collection_name=node_collection,.vectors=node_vectors, - .vectors_len=(size_t)metrics->node_vector_upserts}),db,"user_delta.node_vectors"); - if(rc==0&&metrics->token_vector_upserts)rc=status_ok(zova_vector_put_many(&(zova_vector_put_many_request){ - .db=db,.collection_name=token_collection,.vectors=token_vectors, - .vectors_len=(size_t)metrics->token_vector_upserts}),db,"user_delta.token_vectors"); - free(node_vectors);free(token_vectors);free(node_vector_ids);free(token_vector_ids); - if(rc==0&&sidecar_test_fault("user_delta_after_vector_puts"))rc=-1; - if(rc==0){*out_node_keys=node_keys;*out_node_key_count=(int)node_count;*out_edge_keys=edge_keys;} - else{free(node_keys);free(edge_keys);} - return rc; -} - -static int user_delta_upsert_metadata_tx( - zova_database *db, const char *workspace_id, int64_t workspace_key, - const cbm_zova_workspace_delta_t *delta, - const cbm_zova_workspace_delta_metrics_t *metrics, - const user_file_key_t *file_keys, int file_key_count, - const user_token_key_t *token_keys, int token_key_count) { - zova_statement *statement = NULL; - int rc = metrics->token_vector_upserts ? prepare_zova(db, - "INSERT INTO cbm_token_vector_metadata_v1(token_key,workspace_key,token,idf) " - "VALUES(?1,?2,?3,?4) ON CONFLICT(workspace_key,token) DO UPDATE SET " - "idf=excluded.idf",&statement,"user_delta.token_upsert_prepare") : 0; - for(uint64_t i=0;rc==0&&itoken_vector_upserts;i++){ - const cbm_zova_publish_token_vector_t *row= - cbm_zova_workspace_delta_token_vector_upsert_at(delta,(int)i); - int64_t token_key=user_token_key_find(token_keys,token_key_count,row->source->token); - if(token_key<=0)rc=-1; - if(rc==0)rc=workspace_bind_i64(db,statement,1,token_key,"user_delta.token_key"); - if(rc==0)rc=workspace_bind_i64(db,statement,2,workspace_key,"user_delta.token_ws"); - if(rc==0)rc=workspace_bind_text(db,statement,3,row->source->token,"user_delta.token_value"); - if(rc==0){zova_statement_bind_double_request bind={.statement=statement,.index=4,.value=row->source->idf}; - rc=status_ok(zova_statement_bind_double(&bind),db,"user_delta.token_idf");} - if(rc==0)rc=workspace_step_done(db,statement,"user_delta.token_step"); - if(rc==0)rc=user_database_reset_statement(db,statement,"user_delta.token_reset"); - } - if(statement)(void)zova_statement_finalize(statement); - statement=NULL; - if(rc==0&&metrics->file_hash_upserts)rc=prepare_zova(db, - "INSERT INTO cbm_file_hashes_v1(file_key,content_hash,mtime_ns,size_bytes) " - "VALUES(?1,?2,?3,?4) ON CONFLICT(file_key) DO UPDATE SET " - "content_hash=excluded.content_hash,mtime_ns=excluded.mtime_ns,size_bytes=excluded.size_bytes", - &statement,"user_delta.hash_upsert_prepare"); - for(uint64_t i=0;rc==0&&ifile_hash_upserts;i++){ - const cbm_zova_file_hash_input_t *row=cbm_zova_workspace_delta_file_hash_upsert_at(delta,(int)i); - int64_t file_key = user_file_key_find(file_keys, file_key_count, row->file_path); - if (file_key <= 0) rc=-1; - if(rc==0)rc=workspace_bind_i64(db,statement,1,file_key,"user_delta.hash_file_key"); - if(rc==0)rc=workspace_bind_text(db,statement,2,row->content_hash,"user_delta.hash_value"); - if(rc==0)rc=workspace_bind_i64(db,statement,3,row->mtime_ns,"user_delta.hash_mtime"); - if(rc==0)rc=workspace_bind_i64(db,statement,4,row->size_bytes,"user_delta.hash_size"); - if(rc==0)rc=workspace_step_done(db,statement,"user_delta.hash_step"); - if(rc==0)rc=user_database_reset_statement(db,statement,"user_delta.hash_reset"); - } - if(statement)(void)zova_statement_finalize(statement); - statement=NULL; - if(rc==0&&cbm_zova_workspace_delta_replaces_summary(delta)) - rc=user_database_write_summary(db,workspace_id,cbm_zova_workspace_delta_summary(delta)); - const bool may_have_orphan_files = metrics->node_deletes || metrics->node_updates || - metrics->file_hash_deletes; - if (rc == 0 && may_have_orphan_files) { - rc = prepare_zova(db, - "DELETE FROM cbm_files_v1 WHERE workspace_key=?1 " - "AND NOT EXISTS(SELECT 1 FROM cbm_nodes_v1 n " - "WHERE n.workspace_key=cbm_files_v1.workspace_key " - "AND n.file_key=cbm_files_v1.file_key) " - "AND NOT EXISTS(SELECT 1 FROM cbm_file_hashes_v1 h WHERE h.file_key=cbm_files_v1.file_key)", - &statement, "user_delta.files_cleanup_prepare"); - if (rc == 0) rc = workspace_bind_i64(db, statement, 1, workspace_key, - "user_delta.files_cleanup_ws"); - if (rc == 0) rc = workspace_step_done(db, statement, - "user_delta.files_cleanup_step"); - if (statement) (void)zova_statement_finalize(statement); - } - if(rc==0&&sidecar_test_fault("user_delta_after_metadata_upserts"))rc=-1; - return rc; -} - -static int cbm_zova_user_database_publish_delta_tx( - zova_database *db, const cbm_zova_publish_model_t *model, - const cbm_zova_workspace_delta_t *delta, - cbm_zova_workspace_generation_result_t *out_result) { - if(!db||!model||!delta||!out_result)return -1; - const cbm_zova_workspace_generation_input_t *input=cbm_zova_publish_model_input(model); - const char *model_workspace_id=cbm_zova_publish_model_workspace_id(model); - const cbm_zova_workspace_generation_result_t *digests=cbm_zova_publish_model_digests(model); - if(!input||!model_workspace_id||!digests)return -1; - *out_result=*digests; - cbm_zova_workspace_delta_metrics_t metrics={0}; - cbm_zova_workspace_delta_metrics(delta,&metrics); - cbm_zova_publish_model_metrics_t model_metrics={0}; - cbm_zova_publish_model_metrics(model,&model_metrics); - out_result->publication_mode=CBM_ZOVA_PUBLICATION_MODE_DELTA; - out_result->inserted_count=metrics.node_inserts+metrics.edge_inserts; - out_result->updated_count=metrics.node_updates+metrics.node_vector_upserts+ - metrics.token_vector_upserts+metrics.file_hash_upserts+metrics.summary_replacements; - out_result->deleted_count=metrics.node_deletes+metrics.edge_deletes+ - metrics.node_vector_deletes+metrics.token_vector_deletes+metrics.file_hash_deletes; - out_result->normalization_ms=model_metrics.normalization_ms; - out_result->model_nodes_ms=model_metrics.nodes_ms; - out_result->model_edges_ms=model_metrics.edges_ms; - out_result->model_edge_endpoint_ms=model_metrics.prepared_endpoint_ms; - out_result->model_edge_sort_ms=model_metrics.prepared_topology_sort_ms; - out_result->model_edge_group_ms=model_metrics.prepared_topology_group_ms; - out_result->model_edge_payload_ms=model_metrics.prepared_payload_ms; - out_result->model_edge_digest_ms=model_metrics.prepared_topology_digest_ms; - out_result->model_edge_default_payloads= - model_metrics.prepared_single_default_payload_count; - out_result->model_edge_payload_scratch_edges= - model_metrics.prepared_payload_scratch_edge_count; - out_result->model_hashes_ms=model_metrics.hashes_ms; - out_result->model_vectors_ms=model_metrics.vectors_ms; - out_result->model_digests_ms=model_metrics.digests_ms; - out_result->diff_ms=metrics.diff_ms; - out_result->full_clear_count=0; - out_result->unchanged_rewrite_count=0; - out_result->nodes_inserted=metrics.node_inserts; - out_result->nodes_updated=metrics.node_updates; - out_result->nodes_deleted=metrics.node_deletes; - out_result->edges_inserted=metrics.edge_inserts; - out_result->edges_deleted=metrics.edge_deletes; - out_result->node_vectors_upserted=metrics.node_vector_upserts; - out_result->node_vectors_deleted=metrics.node_vector_deletes; - out_result->token_vectors_upserted=metrics.token_vector_upserts; - out_result->token_vectors_deleted=metrics.token_vector_deletes; - if(cbm_zova_register_sql_functions(db)!=0)return -1; - char workspace_id[CBM_ZOVA_WORKSPACE_ID_MAX]={0}; - if(user_workspace_id_tx(db,input->root_path,workspace_id,sizeof(workspace_id))!=0|| - strcmp(workspace_id,model_workspace_id)!=0)return -1; - int64_t workspace_key = 0; - if (workspace_key_for_id(db, workspace_id, &workspace_key) != 0) return -1; - int64_t expected_generation=cbm_zova_workspace_delta_expected_generation(delta); - int64_t active_generation=0; - if(expected_generation<=0|| - zova_query_i64(db, - "SELECT r.active_generation FROM cbm_workspace_registry r " - "JOIN cbm_database_generation_v1 g ON g.workspace_key=r.workspace_key " - "AND g.generation=r.active_generation AND g.state='ready' " - "WHERE r.workspace_id=?1",workspace_id,&active_generation, - "user_delta.active_generation")!=0|| - active_generation!=expected_generation)return -1; - int64_t current_max=0; - if(zova_query_i64(db,"SELECT COALESCE(MAX(generation),0) FROM cbm_database_generation_v1 " - "WHERE workspace_key=(SELECT workspace_key FROM cbm_workspace_registry " - "WHERE workspace_id=?1)",workspace_id,¤t_max,"user_delta.current_generation")!=0|| - current_max<=0||current_max==INT64_MAX)return -1; - int64_t generation=current_max+1; - if(user_publish_insert_generation_tx(db,workspace_id,generation)!=0)return -1; - if(sidecar_test_fault("user_delta_after_generation"))return -1; - - char node_collection[ZV_ID_MAX+CBM_ZOVA_WORKSPACE_ID_MAX+96]; - char token_collection[ZV_ID_MAX+CBM_ZOVA_WORKSPACE_ID_MAX+96]; - if(cbm_zova_workspace_node_vector_collection_name(workspace_id,input->model_fingerprint, - input->vector_dimensions,node_collection,sizeof(node_collection))!=0|| - cbm_zova_workspace_token_vector_collection_name(workspace_id,input->model_fingerprint, - input->vector_dimensions,token_collection,sizeof(token_collection))!=0)return -1; - int rc=0; - user_file_key_t *file_keys = NULL; - int file_key_count = 0; - user_node_key_t *delta_node_keys = NULL; - int delta_node_key_count = 0; - int64_t *delta_edge_keys = NULL; - user_token_key_t *delta_token_keys = NULL; - int delta_token_key_count = 0; - node_key_index_t node_index = {0}; - if(metrics.node_deletes||metrics.node_vector_deletes||metrics.node_vector_upserts) - rc=node_key_index_build(db,workspace_id,&node_index); - if(rc==0)rc=user_delta_delete_canonical_tx(db,workspace_key,&node_index,delta,&metrics); - if(rc==0)rc=user_delta_delete_native_tx(db,workspace_id,&node_index,node_collection, - token_collection,delta,&metrics); - if(rc==0)rc=user_publish_project_tx(db,workspace_id,input->root_path,input->project,input->indexed_at); - if(rc==0)rc=user_delta_file_keys_tx(db,workspace_key,delta,&metrics, - &file_keys,&file_key_count); - if(rc==0)rc=user_delta_token_keys_tx(db,workspace_key,delta,&metrics, - &delta_token_keys,&delta_token_key_count); - if(rc==0)rc=user_delta_put_native_tx(db,workspace_id,workspace_key,&node_index, - node_collection,token_collection, - delta,&metrics,model,&delta_node_keys, - &delta_node_key_count,&delta_edge_keys, - delta_token_keys,delta_token_key_count); - node_key_index_free(&node_index); - if(rc==0)rc=user_delta_upsert_nodes_tx(db,workspace_key,model,delta,&metrics, - file_keys,file_key_count,delta_node_keys, - delta_node_key_count); - if(rc==0&&sidecar_test_fault("user_delta_after_node_upserts"))rc=-1; - if(rc==0&&sidecar_test_fault("user_delta_after_edge_inserts"))rc=-1; - if(rc==0)rc=user_delta_upsert_metadata_tx(db,workspace_id,workspace_key,delta,&metrics, - file_keys,file_key_count,delta_token_keys, - delta_token_key_count); - out_result->generation=generation; - - char graph_name[ZV_ID_MAX+CBM_ZOVA_WORKSPACE_ID_MAX]; - zova_graph_info graph_info={0}; - if(rc==0&&cbm_zova_workspace_graph_name(workspace_id,graph_name,sizeof(graph_name))!=0)rc=-1; - if(rc==0)rc=status_ok(zova_graph_info_get(&(zova_graph_info_get_request){ - .db=db,.name=graph_name,.out_info=&graph_info}),db,"user_delta.graph_readback"); - if(rc==0&&(graph_info.node_count!=out_result->graph_nodes|| - graph_info.edge_count!=out_result->graph_edges))rc=-1; - zova_graph_info_free(&graph_info); - if(rc==0)rc=user_publish_native_readback_counts_tx(db,out_result,node_collection, - token_collection); - if(rc==0){user_publish_test_metrics.integrity_writes++; - rc=user_publish_integrity_tx(db,workspace_id,generation,out_result);} - if(rc==0&&sidecar_test_fault("user_delta_after_integrity"))rc=-1; - if(rc==0)rc=user_publish_write_index_state_tx(db,workspace_id,generation, - input->model_fingerprint,input->vector_dimensions,input->indexed_at); - if(rc==0)rc=user_publish_clear_health_tx(db,workspace_id); - if(rc==0)rc=user_publish_generation_state_tx(db,workspace_id,generation,true); - if(rc==0&&sidecar_test_fault("user_delta_before_commit"))rc=-1; - snprintf(out_result->workspace_id,sizeof(out_result->workspace_id),"%s",workspace_id); - if (rc == 0) { - user_publish_test_metrics.delta_authoritative_rows_touched += - metrics.node_vector_deletes + metrics.token_vector_deletes + - metrics.topology_deletes + metrics.node_deletes + - metrics.node_deletes + metrics.node_updates + metrics.edge_deletes + - metrics.token_vector_deletes + metrics.file_hash_deletes + - metrics.node_inserts + metrics.node_updates + - metrics.node_inserts + metrics.node_updates + metrics.edge_inserts + - metrics.topology_inserts + metrics.node_inserts + metrics.node_updates + - metrics.node_vector_upserts + metrics.token_vector_upserts + - metrics.token_vector_upserts + metrics.file_hash_upserts + - metrics.summary_replacements; - } - free(file_keys); - free(delta_node_keys); - free(delta_edge_keys); - free(delta_token_keys); - return rc; -} - -static int user_exec_i64_bound(zova_database *db, const char *sql, - int64_t bind_value, const char *phase) { - if (!db || !sql) return -1; - zova_statement *stmt = NULL; - int rc = prepare_zova(db, sql, &stmt, phase); - if (rc == 0) rc = workspace_bind_i64(db, stmt, 1, bind_value, phase); - if (rc == 0) rc = workspace_step_done(db, stmt, phase); - if (stmt) (void)zova_statement_finalize(stmt); - return rc; -} - -static int user_publish_full_nodes_begin_tx(zova_database *db) { - return status_ok(zova_database_exec(&(zova_database_exec_request){ - .db = db, - .sql = "DROP TRIGGER cbm_nodes_v1_fts_ai;"}), - db, "user_publish.nodes_bulk_begin"); -} - -static int user_publish_full_nodes_end_tx(zova_database *db, int64_t workspace_key, - bool populate) { - int rc = 0; - if (populate) { - rc = user_exec_i64_bound( - db, - "INSERT INTO cbm_nodes_fts_v1(rowid,name,qualified_name,file_path,label) " - "SELECT n.zova_node_key,cbm_camel_split(n.name),n.qualified_name,f.file_path,n.label " - "FROM cbm_nodes_v1 n JOIN cbm_files_v1 f ON f.file_key=n.file_key " - "WHERE n.workspace_key=?1 ORDER BY n.zova_node_key", - workspace_key, "user_publish.fts_bulk_insert"); - if (rc == 0) user_publish_test_metrics.full_fts_bulk_statements++; - } - int restore_rc = status_ok(zova_database_exec(&(zova_database_exec_request){ - .db = db, - .sql = CBM_NODE_FTS_INSERT_TRIGGER_SQL}), - db, "user_publish.nodes_bulk_restore"); - return rc != 0 ? rc : restore_rc; -} - -enum { USER_FRESH_BATCH_ROWS = 512 }; -enum { USER_FRESH_UNAVAILABLE = 1 }; - -static zova_fresh_value user_fresh_i64(int64_t value) { - return (zova_fresh_value){ - .value_type = ZOVA_FRESH_VALUE_INT64, - .int64_value = value, - }; -} - -static zova_fresh_value user_fresh_text(const char *value) { - const char *text = value ? value : ""; - return (zova_fresh_value){ - .value_type = ZOVA_FRESH_VALUE_TEXT, - .bytes = (const uint8_t *)text, - .bytes_len = strlen(text), - }; -} - -static int user_fresh_load_nodes(zova_database *db, zova_fresh_build *build, - const cbm_zova_publish_model_t *model, - int64_t workspace_key, - const user_file_key_t *file_keys, int file_key_count, - const int64_t *node_keys) { - static const char *columns[] = { - "zova_node_key", "workspace_key", "label", "name", "qualified_name", - "file_key", "start_line", "end_line", "properties", "source_ordinal", - }; - const int count = cbm_zova_publish_model_node_count(model); - zova_fresh_value *values = count - ? calloc((size_t)USER_FRESH_BATCH_ROWS * 10, sizeof(*values)) : NULL; - if (count && !values) return -1; - int rc = 0; - user_publish_test_metrics.canonical_node_fts_passes++; - for (int offset = 0; rc == 0 && offset < count; offset += USER_FRESH_BATCH_ROWS) { - int rows = count - offset; - if (rows > USER_FRESH_BATCH_ROWS) rows = USER_FRESH_BATCH_ROWS; - for (int row_index = 0; row_index < rows; row_index++) { - const int index = offset + row_index; - const cbm_zova_publish_node_t *row = - cbm_zova_publish_model_node_at(model, index); - const CBMDumpNode *source = row ? row->source : NULL; - int64_t file_key = source - ? user_file_key_find(file_keys, file_key_count, source->file_path) : 0; - if (!row || !source || !node_keys || node_keys[index] <= 0 || - file_key <= 0 || row->ordinal != (uint64_t)index) { - rc = -1; - break; - } - zova_fresh_value *out = values + (size_t)row_index * 10; - out[0] = user_fresh_i64(node_keys[index]); - out[1] = user_fresh_i64(workspace_key); - out[2] = user_fresh_text(source->label); - out[3] = user_fresh_text(source->name); - out[4] = user_fresh_text(source->qualified_name); - out[5] = user_fresh_i64(file_key); - out[6] = user_fresh_i64(source->start_line); - out[7] = user_fresh_i64(source->end_line); - out[8] = user_fresh_text(source->properties ? source->properties : "{}"); - out[9] = user_fresh_i64((int64_t)row->source_ordinal); - } - if (rc == 0) - rc = status_ok(zova_fresh_build_table_rows( - &(zova_fresh_build_rows_request){ - .build = build, - .table_name = "cbm_nodes_v1", - .column_names = columns, - .column_count = sizeof(columns) / sizeof(columns[0]), - .values = values, - .row_count = (size_t)rows}), - db, "user_publish.fresh_nodes"); - } - free(values); - return rc; -} - -static int user_fresh_load_fts(zova_database *db, zova_fresh_build *build, - const cbm_zova_publish_model_t *model, - const int64_t *node_keys) { - static const char *columns[] = { - "rowid", "name", "qualified_name", "file_path", "label", - }; - const int count = cbm_zova_publish_model_node_count(model); - zova_fresh_value *values = count - ? calloc((size_t)USER_FRESH_BATCH_ROWS * 5, sizeof(*values)) : NULL; - if (count && !values) return -1; - int rc = 0; - for (int offset = 0; rc == 0 && offset < count; offset += USER_FRESH_BATCH_ROWS) { - int rows = count - offset; - if (rows > USER_FRESH_BATCH_ROWS) rows = USER_FRESH_BATCH_ROWS; - for (int row_index = 0; row_index < rows; row_index++) { - const int index = offset + row_index; - const cbm_zova_publish_node_t *row = - cbm_zova_publish_model_node_at(model, index); - const CBMDumpNode *source = row ? row->source : NULL; - const char *fts_name = cbm_zova_publish_model_fts_name_at(model, index); - if (!row || !source || !fts_name || !node_keys || node_keys[index] <= 0) { - rc = -1; - break; - } - zova_fresh_value *out = values + (size_t)row_index * 5; - out[0] = user_fresh_i64(node_keys[index]); - out[1] = user_fresh_text(fts_name); - out[2] = user_fresh_text(source->qualified_name); - out[3] = user_fresh_text(source->file_path); - out[4] = user_fresh_text(source->label); - } - if (rc == 0) - rc = status_ok(zova_fresh_build_fts_rows( - &(zova_fresh_build_rows_request){ - .build = build, - .table_name = "cbm_nodes_fts_v1", - .column_names = columns, - .column_count = sizeof(columns) / sizeof(columns[0]), - .values = values, - .row_count = (size_t)rows}), - db, "user_publish.fresh_fts"); - } - free(values); - return rc; -} - -static int user_fresh_load_node_vectors( - zova_database *db, zova_fresh_build *build, - const cbm_zova_publish_model_t *model, const char *collection, - const int64_t *node_keys) { - const cbm_zova_workspace_generation_input_t *input = - cbm_zova_publish_model_input(model); - const int count = cbm_zova_publish_model_node_vector_count(model); - zova_vector_input *vectors = count - ? calloc(USER_FRESH_BATCH_ROWS, sizeof(*vectors)) : NULL; - char (*ids)[32] = count ? calloc(USER_FRESH_BATCH_ROWS, sizeof(*ids)) : NULL; - if (count && (!vectors || !ids)) { - free(vectors); - free(ids); - return -1; - } - int rc = 0; - for (int offset = 0; rc == 0 && offset < count; offset += USER_FRESH_BATCH_ROWS) { - int rows = count - offset; - if (rows > USER_FRESH_BATCH_ROWS) rows = USER_FRESH_BATCH_ROWS; - for (int row_index = 0; row_index < rows; row_index++) { - const int index = offset + row_index; - const cbm_zova_publish_node_vector_t *row = - cbm_zova_publish_model_node_vector_at(model, index); - int64_t key = row ? node_keys[row->node_ordinal] : 0; - if (!row || row->ordinal != (uint64_t)index || key <= 0 || - user_private_key_id(key, ids[row_index]) != 0) { - rc = -1; - break; - } - vectors[row_index] = (zova_vector_input){ - .id = ids[row_index], - .values = { - .element_type = ZOVA_VECTOR_ELEMENT_TYPE_I8, - .i8_values = (const int8_t *)row->source->vector, - .values_len = (size_t)input->vector_dimensions, - }, - }; - } - if (rc == 0) - rc = status_ok(zova_fresh_build_vectors( - &(zova_fresh_build_vectors_request){ - .build = build, - .collection_name = collection, - .vectors = vectors, - .vectors_len = (size_t)rows}), - db, "user_publish.fresh_node_vectors"); - } - free(vectors); - free(ids); - return rc; -} - -static int user_fresh_load_token_vectors( - zova_database *db, zova_fresh_build *build, - const cbm_zova_publish_model_t *model, const char *collection, - const user_token_key_t *token_keys, int token_key_count) { - const cbm_zova_workspace_generation_input_t *input = - cbm_zova_publish_model_input(model); - const int count = cbm_zova_publish_model_token_vector_count(model); - zova_vector_input *vectors = count - ? calloc(USER_FRESH_BATCH_ROWS, sizeof(*vectors)) : NULL; - char (*ids)[32] = count ? calloc(USER_FRESH_BATCH_ROWS, sizeof(*ids)) : NULL; - if (count && (!vectors || !ids)) { - free(vectors); - free(ids); - return -1; - } - int rc = count == token_key_count ? 0 : -1; - for (int offset = 0; rc == 0 && offset < count; offset += USER_FRESH_BATCH_ROWS) { - int rows = count - offset; - if (rows > USER_FRESH_BATCH_ROWS) rows = USER_FRESH_BATCH_ROWS; - for (int row_index = 0; row_index < rows; row_index++) { - const int index = offset + row_index; - const cbm_zova_publish_token_vector_t *row = - cbm_zova_publish_model_token_vector_at(model, index); - int64_t key = token_keys[index].token_key; - if (!row || !row->source || row->ordinal != (uint64_t)index || key <= 0 || - strcmp(token_keys[index].token, row->source->token) != 0 || - user_private_key_id(key, ids[row_index]) != 0) { - rc = -1; - break; - } - vectors[row_index] = (zova_vector_input){ - .id = ids[row_index], - .values = { - .element_type = ZOVA_VECTOR_ELEMENT_TYPE_I8, - .i8_values = (const int8_t *)row->source->vector, - .values_len = (size_t)input->vector_dimensions, - }, - }; - } - if (rc == 0) - rc = status_ok(zova_fresh_build_vectors( - &(zova_fresh_build_vectors_request){ - .build = build, - .collection_name = collection, - .vectors = vectors, - .vectors_len = (size_t)rows}), - db, "user_publish.fresh_token_vectors"); - } - free(vectors); - free(ids); - return rc; -} - -static int user_publish_can_use_fresh_session(zova_database *db, bool *out_fresh) { - if (!db || !out_fresh) return -1; - *out_fresh = false; - zova_graph_list graphs = {0}; - int rc = status_ok(zova_graphs_list(&(zova_graph_list_request){ - .db = db, .out_list = &graphs}), - db, "user_publish.fresh_graph_list"); - if (rc == 0) *out_fresh = graphs.len == 0; - zova_graph_list_free(&graphs); - return rc; -} - -typedef struct { - user_native_graph_profile_t graph; - double validation_ms; - double table_ms; - double fts_ms; - double vector_ms; - double index_ms; - double commit_ms; - double build_ms; -} user_fresh_publish_profile_t; - -static int user_publish_model_fresh_session_tx( - zova_database *db, const cbm_zova_publish_model_t *model, - int64_t workspace_key, const user_file_key_t *file_keys, int file_key_count, - const user_token_key_t *token_keys, int token_key_count, - const char *node_collection, const char *token_collection, - uint64_t *out_nodes, uint64_t *out_edges, - user_fresh_publish_profile_t *out_profile) { - if (!db || !model || !node_collection || !token_collection || !out_profile) - return -1; - memset(out_profile, 0, sizeof(*out_profile)); - const cbm_zova_workspace_generation_input_t *input = - cbm_zova_publish_model_input(model); - const char *workspace_id = cbm_zova_publish_model_workspace_id(model); - char graph_name[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX]; - if (!input || !workspace_id || - cbm_zova_workspace_graph_name(workspace_id, graph_name, sizeof(graph_name)) != 0) - return -1; - const int node_count = cbm_zova_publish_model_node_count(model); - const int edge_count = cbm_zova_publish_model_topology_count(model); - zova_graph_fresh_node_input *nodes = node_count - ? calloc((size_t)node_count, sizeof(*nodes)) : NULL; - zova_graph_fresh_edge_payload_input *edges = edge_count - ? calloc((size_t)edge_count, sizeof(*edges)) : NULL; - int64_t *node_keys = node_count - ? calloc((size_t)node_count, sizeof(*node_keys)) : NULL; - int64_t *edge_keys = edge_count - ? calloc((size_t)edge_count, sizeof(*edge_keys)) : NULL; - if ((node_count && (!nodes || !node_keys)) || - (edge_count && (!edges || !edge_keys))) { - free(nodes); free(edges); free(node_keys); free(edge_keys); - return -1; - } - struct timespec mark = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &mark); - int rc = 0; - for (int i = 0; rc == 0 && i < node_count; i++) { - const cbm_zova_publish_node_t *row = cbm_zova_publish_model_node_at(model, i); - if (!row || !row->source || row->ordinal != (uint64_t)i) { rc = -1; break; } - nodes[i] = (zova_graph_fresh_node_input){ - .node_id = row->stable_id, - .kind = row->source->label, - .target_type = ZOVA_GRAPH_TARGET_NONE, - }; - } - for (int i = 0; rc == 0 && i < edge_count; i++) { - const cbm_zova_publish_edge_t *row = cbm_zova_publish_model_topology_at(model, i); - if (!row || !row->source || row->ordinal != (uint64_t)i) { rc = -1; break; } - edges[i] = (zova_graph_fresh_edge_payload_input){ - .from_node_ordinal = (size_t)row->source_ordinal, - .edge_type = row->source->type, - .to_node_ordinal = (size_t)row->target_ordinal, - .payload = row->payload, - .payload_len = row->payload_len, - }; - } - out_profile->graph.materialize_ms = - user_publish_profile_phase("fresh.materialize", &mark); - if (rc == 0) rc = collection_create_i8(db, node_collection, input->vector_dimensions); - if (rc == 0) rc = collection_create_i8(db, token_collection, input->vector_dimensions); - if (rc == 0) rc = user_publish_full_nodes_begin_tx(db); - - zova_fresh_build *build = NULL; - if (rc == 0) { - zova_status begin_status = zova_fresh_build_begin( - &(zova_fresh_build_begin_request){.db = db, .out_build = &build}); - if (begin_status == ZOVA_INVALID_ARGUMENT) { - int restore_rc = user_publish_full_nodes_end_tx(db, workspace_key, false); - free(nodes); - free(edges); - free(node_keys); - free(edge_keys); - return restore_rc == 0 ? USER_FRESH_UNAVAILABLE : -1; - } - rc = status_ok(begin_status, db, "user_publish.fresh_begin"); - } - out_profile->graph.reset_ms = user_publish_profile_phase("fresh.begin", &mark); - struct timespec fresh_started = mark; - if (rc == 0) { - user_publish_test_metrics.native_graph_fresh_calls++; - user_publish_test_metrics.native_graph_prepared_calls++; - rc = status_ok(zova_fresh_build_graph(&(zova_fresh_build_graph_request){ - .build = build, - .graph_name = graph_name, - .nodes = nodes, - .nodes_len = (size_t)node_count, - .edges = edges, - .edges_len = (size_t)edge_count, - .out_node_keys = node_keys, - .out_node_keys_capacity = (size_t)node_count, - .out_edge_keys = edge_keys, - .out_edge_keys_capacity = (size_t)edge_count}), - db, "user_publish.fresh_graph"); - } - if (rc == 0 && sidecar_test_fault("user_after_graph_nodes")) rc = -1; - if (rc == 0 && sidecar_test_fault("user_after_graph_edges")) rc = -1; - if (rc == 0) rc = user_fresh_load_fts(db, build, model, node_keys); - if (rc == 0 && sidecar_test_fault("user_after_bulk_fts")) rc = -1; - if (rc == 0) rc = user_fresh_load_nodes( - db, build, model, workspace_key, file_keys, file_key_count, node_keys); - if (rc == 0 && sidecar_test_fault("user_after_nodes_before_bulk_finalize")) rc = -1; - if (rc == 0 && sidecar_test_fault("user_after_edges_before_bulk_finalize")) rc = -1; - if (rc == 0) rc = user_fresh_load_node_vectors( - db, build, model, node_collection, node_keys); - if (rc == 0 && sidecar_test_fault("user_after_node_vectors")) rc = -1; - if (rc == 0) rc = user_fresh_load_token_vectors( - db, build, model, token_collection, token_keys, token_key_count); - if (rc == 0 && sidecar_test_fault("user_after_token_vectors")) rc = -1; - - zova_fresh_build_profile profile = {0}; - if (rc == 0) - rc = status_ok(zova_fresh_build_finish(&(zova_fresh_build_finish_request){ - .build = build, - .out_node_keys = node_keys, - .out_node_keys_capacity = (size_t)node_count, - .out_edge_keys = edge_keys, - .out_edge_keys_capacity = (size_t)edge_count, - .out_profile = &profile}), - db, "user_publish.fresh_finish"); - if (rc != 0 && build) (void)zova_fresh_build_abort(build); - if (build) zova_fresh_build_destroy(build); - if (rc == 0) rc = user_publish_full_nodes_end_tx(db, workspace_key, false); - if (rc == 0) { - for (int i = 0; i < node_count; i++) if (node_keys[i] <= 0) rc = -1; - for (int i = 0; i < edge_count; i++) if (edge_keys[i] <= 0) rc = -1; - } - struct timespec fresh_completed = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &fresh_completed); - out_profile->build_ms = user_publish_elapsed_ms(&fresh_started, &fresh_completed); - out_profile->validation_ms = profile.validation_ms; - out_profile->graph.nodes_ms = profile.graph_node_load_ms; - out_profile->graph.edges_ms = profile.graph_edge_load_ms; - out_profile->graph.validate_ms = profile.graph_validation_ms; - out_profile->graph.key_generation_ms = profile.graph_key_generation_ms; - out_profile->table_ms = profile.table_load_ms; - out_profile->fts_ms = profile.fts_load_ms; - out_profile->vector_ms = profile.vector_load_ms; - out_profile->index_ms = profile.index_build_ms; - out_profile->commit_ms = profile.commit_ms; - if (rc == 0) { - if (out_nodes) *out_nodes = (uint64_t)node_count; - if (out_edges) *out_edges = (uint64_t)edge_count; - user_publish_test_metrics.full_fts_trigger_rows_avoided += (uint64_t)node_count; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &mark); - free(nodes); - free(edges); - free(node_keys); - free(edge_keys); - out_profile->graph.cleanup_ms = user_publish_profile_phase("fresh.cleanup", &mark); - return rc; -} - -int cbm_zova_user_database_publish_model_tx( - zova_database *db, const cbm_zova_publish_model_t *model, - int64_t requested_generation, cbm_zova_workspace_generation_result_t *out_result) { - const cbm_zova_workspace_generation_input_t *input = cbm_zova_publish_model_input(model); - if (!db || !input || !out_result || !input->root_path || !input->root_path[0] || - !input->project || !input->project[0] || !input->indexed_at || !input->indexed_at[0] || - !input->model_fingerprint || !input->model_fingerprint[0] || input->vector_dimensions <= 0 || - input->node_count < 0 || input->edge_count < 0 || input->node_vector_count < 0 || - input->token_vector_count < 0 || input->file_hash_count < 0 || - (input->node_count > 0 && !input->nodes) || (input->edge_count > 0 && !input->edges) || - (input->node_vector_count > 0 && !input->node_vectors) || - (input->token_vector_count > 0 && !input->token_vectors) || - (input->file_hash_count > 0 && !input->file_hashes)) { - return -1; - } - struct timespec profile_mark = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &profile_mark); - const cbm_zova_workspace_generation_result_t *model_result = - cbm_zova_publish_model_digests(model); - const char *model_workspace_id = cbm_zova_publish_model_workspace_id(model); - if (!model_result || !model_workspace_id) return -1; - *out_result = *model_result; - cbm_zova_publish_model_metrics_t model_metrics = {0}; - cbm_zova_publish_model_metrics(model, &model_metrics); - out_result->publication_mode = CBM_ZOVA_PUBLICATION_MODE_FULL; - out_result->inserted_count = out_result->metadata_nodes + out_result->metadata_edges + - out_result->node_vectors + out_result->token_vectors + - (uint64_t)input->file_hash_count; - out_result->normalization_ms = model_metrics.normalization_ms; - out_result->model_nodes_ms = model_metrics.nodes_ms; - out_result->model_edges_ms = model_metrics.edges_ms; - out_result->model_edge_endpoint_ms = model_metrics.prepared_endpoint_ms; - out_result->model_edge_sort_ms = model_metrics.prepared_topology_sort_ms; - out_result->model_edge_group_ms = model_metrics.prepared_topology_group_ms; - out_result->model_edge_payload_ms = model_metrics.prepared_payload_ms; - out_result->model_edge_digest_ms = model_metrics.prepared_topology_digest_ms; - out_result->model_edge_default_payloads = - model_metrics.prepared_single_default_payload_count; - out_result->model_edge_payload_scratch_edges = - model_metrics.prepared_payload_scratch_edge_count; - out_result->model_hashes_ms = model_metrics.hashes_ms; - out_result->model_vectors_ms = model_metrics.vectors_ms; - out_result->model_digests_ms = model_metrics.digests_ms; - out_result->full_clear_count = 1; - out_result->nodes_inserted = out_result->metadata_nodes; - out_result->edges_inserted = out_result->metadata_edges; - out_result->node_vectors_upserted = out_result->node_vectors; - out_result->token_vectors_upserted = out_result->token_vectors; - if (cbm_zova_register_sql_functions(db) != 0) return -1; - if (status_ok(zova_database_exec(&(zova_database_exec_request){ - .db = db, .sql = "PRAGMA cache_size=-65536"}), - db, "user_publish.cache_size") != 0) { - return -1; - } - if (!workspace_name_component_valid(input->model_fingerprint)) return -1; - (void)user_publish_profile_phase("normalize", &profile_mark); - - char workspace_id[CBM_ZOVA_WORKSPACE_ID_MAX] = {0}; - if (user_workspace_id_tx(db, input->root_path, workspace_id, sizeof(workspace_id)) != 0 || - strcmp(workspace_id, model_workspace_id) != 0) return -1; - int64_t workspace_key = 0; - if (workspace_key_for_id(db, workspace_id, &workspace_key) != 0) return -1; - int64_t current_max = 0; - int64_t generation = 0; - if (zova_query_i64(db, - "SELECT COALESCE(MAX(generation),0) FROM cbm_database_generation_v1 " - "WHERE workspace_key=(SELECT workspace_key FROM cbm_workspace_registry " - "WHERE workspace_id=?1)", - workspace_id, ¤t_max, "user_publish.current_generation") != 0 || - current_max < 0 || - (requested_generation > 0 && requested_generation <= current_max) || - (requested_generation == 0 && current_max == INT64_MAX)) { - return -1; - } - generation = requested_generation > 0 ? requested_generation : current_max + 1; - if (generation <= 0 || user_publish_insert_generation_tx(db, workspace_id, generation) != 0) - return -1; - - user_publish_test_metrics.full_clear_count++; - int rc = user_database_clear_workspace(db, workspace_id); - user_file_key_t *file_keys = NULL; - int file_key_count = 0; - user_node_key_t *node_keys = NULL; - user_token_key_t *token_keys = NULL; - int token_key_count = 0; - user_topology_key_t *topology_keys = NULL; - out_result->clear_ms = user_publish_profile_phase("clear", &profile_mark); - if (rc == 0) rc = user_publish_project_tx(db, workspace_id, input->root_path, input->project, - input->indexed_at); - if (rc == 0) rc = user_publish_file_keys_tx(db, model, workspace_key, &file_keys, - &file_key_count); - out_result->canonical_files_ms = - user_publish_profile_phase("canonical_files", &profile_mark); - - char node_collection[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX + 96]; - char token_collection[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX + 96]; - if (rc == 0 && (cbm_zova_workspace_node_vector_collection_name( - workspace_id, input->model_fingerprint, input->vector_dimensions, - node_collection, sizeof(node_collection)) != 0 || - cbm_zova_workspace_token_vector_collection_name( - workspace_id, input->model_fingerprint, input->vector_dimensions, - token_collection, sizeof(token_collection)) != 0)) { - rc = -1; - } - - uint64_t graph_nodes = 0; - uint64_t graph_edges = 0; - bool use_fresh_session = false; - if (rc == 0) rc = user_publish_can_use_fresh_session(db, &use_fresh_session); - bool hashes_published = false; - bool token_metadata_published = false; - if (rc == 0 && use_fresh_session) { - if (rc == 0) rc = user_publish_model_hashes_tx( - db, model, file_keys, file_key_count); - if (rc == 0) rc = user_database_write_summary( - db, workspace_id, &input->project_summary); - hashes_published = rc == 0; - out_result->canonical_hashes_ms = - user_publish_profile_phase("canonical_hashes", &profile_mark); - if (rc == 0) rc = user_publish_model_token_metadata_tx( - db, model, workspace_key, &token_keys, &token_key_count); - token_metadata_published = rc == 0; - out_result->token_metadata_ms = - user_publish_profile_phase("token_metadata", &profile_mark); - if (rc == 0 && sidecar_test_fault("user_after_metadata")) rc = -1; - - user_fresh_publish_profile_t fresh_profile = {0}; - if (rc == 0) { - int fresh_rc = user_publish_model_fresh_session_tx( - db, model, workspace_key, file_keys, file_key_count, - token_keys, token_key_count, node_collection, token_collection, - &graph_nodes, &graph_edges, &fresh_profile); - if (fresh_rc == USER_FRESH_UNAVAILABLE) { - use_fresh_session = false; - cbm_clock_gettime(CLOCK_MONOTONIC, &profile_mark); - } else { - rc = fresh_rc; - } - } - if (use_fresh_session) { - out_result->native_graph_materialize_ms = fresh_profile.graph.materialize_ms; - out_result->native_graph_reset_ms = fresh_profile.graph.reset_ms; - out_result->native_graph_nodes_ms = fresh_profile.graph.nodes_ms; - out_result->native_graph_edges_ms = fresh_profile.graph.edges_ms; - out_result->native_graph_validate_ms = fresh_profile.graph.validate_ms; - out_result->native_graph_key_generation_ms = - fresh_profile.graph.key_generation_ms; - out_result->native_graph_cleanup_ms = fresh_profile.graph.cleanup_ms; - out_result->native_graph_ms = fresh_profile.graph.materialize_ms + - fresh_profile.graph.reset_ms + fresh_profile.graph.nodes_ms + - fresh_profile.graph.edges_ms + fresh_profile.graph.validate_ms + - fresh_profile.graph.key_generation_ms + fresh_profile.graph.cleanup_ms; - out_result->canonical_nodes_ms = fresh_profile.table_ms; - out_result->fts_ms = fresh_profile.fts_ms; - out_result->canonical_edges_ms = 0.0; - out_result->native_vectors_ms = fresh_profile.vector_ms; - out_result->fresh_validation_ms = fresh_profile.validation_ms; - out_result->fresh_index_ms = fresh_profile.index_ms; - out_result->fresh_commit_ms = fresh_profile.commit_ms; - out_result->fresh_build_ms = fresh_profile.build_ms; - cbm_clock_gettime(CLOCK_MONOTONIC, &profile_mark); - if (rc == 0 && sidecar_test_fault("user_after_fts")) rc = -1; - } - } - if (rc == 0 && !use_fresh_session) { - user_native_graph_profile_t graph_profile = {0}; - rc = user_publish_model_graph_tx(db, model, &node_keys, &topology_keys, - &graph_nodes, &graph_edges, &graph_profile); - out_result->native_graph_materialize_ms = graph_profile.materialize_ms; - out_result->native_graph_reset_ms = graph_profile.reset_ms; - out_result->native_graph_nodes_ms = graph_profile.nodes_ms; - out_result->native_graph_edges_ms = graph_profile.edges_ms; - out_result->native_graph_validate_ms = graph_profile.validate_ms; - out_result->native_graph_key_generation_ms = graph_profile.key_generation_ms; - out_result->native_graph_cleanup_ms = graph_profile.cleanup_ms; - out_result->native_graph_ms = - user_publish_profile_phase("native_graph", &profile_mark); - - bool nodes_bulk_active = false; - if (rc == 0) { - rc = user_publish_full_nodes_begin_tx(db); - nodes_bulk_active = rc == 0; - } - if (rc == 0) rc = user_publish_model_nodes_tx( - db, model, workspace_key, file_keys, file_key_count, node_keys); - out_result->canonical_nodes_ms = - user_publish_profile_phase("canonical_nodes", &profile_mark); - bool populate_fts = rc == 0 && - !sidecar_test_fault("user_after_nodes_before_bulk_finalize"); - if (rc == 0 && !populate_fts) rc = -1; - if (nodes_bulk_active) { - int end_rc = user_publish_full_nodes_end_tx(db, workspace_key, populate_fts); - if (rc == 0) rc = end_rc; - if (populate_fts && end_rc == 0) { - user_publish_test_metrics.full_fts_trigger_rows_avoided += - (uint64_t)input->node_count; - } - } - out_result->fts_ms = user_publish_profile_phase("fts", &profile_mark); - if (rc == 0 && sidecar_test_fault("user_after_bulk_fts")) rc = -1; - if (rc == 0 && sidecar_test_fault("user_after_edges_before_bulk_finalize")) rc = -1; - out_result->canonical_edges_ms = - user_publish_profile_phase("canonical_edges", &profile_mark); - if (!hashes_published) { - if (rc == 0) rc = user_publish_model_hashes_tx( - db, model, file_keys, file_key_count); - if (rc == 0) rc = user_database_write_summary( - db, workspace_id, &input->project_summary); - out_result->canonical_hashes_ms = - user_publish_profile_phase("canonical_hashes", &profile_mark); - } - if (!token_metadata_published) { - if (rc == 0) rc = user_publish_model_token_metadata_tx( - db, model, workspace_key, &token_keys, &token_key_count); - out_result->token_metadata_ms = - user_publish_profile_phase("token_metadata", &profile_mark); - if (rc == 0 && sidecar_test_fault("user_after_metadata")) rc = -1; - } - if (rc == 0 && sidecar_test_fault("user_after_fts")) rc = -1; - if (rc == 0) - rc = user_publish_model_vectors_tx( - db, model, node_collection, token_collection, node_keys, - cbm_zova_publish_model_node_count(model), token_keys, token_key_count); - out_result->native_vectors_ms = - user_publish_profile_phase("native_vectors", &profile_mark); - } - - out_result->generation = generation; - if (rc == 0 && (graph_nodes != out_result->graph_nodes || - graph_edges != out_result->graph_edges)) rc = -1; - (void)user_publish_profile_phase("digests", &profile_mark); - if (rc == 0) rc = user_publish_native_readback_counts_tx( - db, out_result, node_collection, token_collection); - out_result->readback_ms = user_publish_profile_phase("readback", &profile_mark); - if (rc == 0) { - user_publish_test_metrics.integrity_writes++; - rc = user_publish_integrity_tx(db, workspace_id, generation, out_result); - } - if (rc == 0 && sidecar_test_fault("user_after_integrity")) rc = -1; - if (rc == 0) rc = user_publish_write_index_state_tx( - db, workspace_id, generation, input->model_fingerprint, input->vector_dimensions, - input->indexed_at); - if (rc == 0) rc = user_publish_clear_health_tx(db, workspace_id); - if (rc == 0) rc = user_publish_generation_state_tx(db, workspace_id, generation, true); - if (rc == 0 && sidecar_test_fault("user_before_commit")) rc = -1; - out_result->finalize_ms = user_publish_profile_phase("finalize", &profile_mark); - snprintf(out_result->workspace_id, sizeof(out_result->workspace_id), "%s", workspace_id); - free(node_keys); - free(token_keys); - free(topology_keys); - free(file_keys); - return rc; -} - -int cbm_zova_user_database_publish_workspace_tx( - zova_database *db, const cbm_zova_workspace_generation_input_t *input, - cbm_zova_workspace_generation_result_t *out_result) { - char workspace_id[CBM_ZOVA_WORKSPACE_ID_MAX]; - cbm_zova_publish_model_t *model = NULL; - if (!input || cbm_zova_workspace_id_for_root(input->root_path, workspace_id, - sizeof(workspace_id)) != 0 || - cbm_zova_publish_model_build(workspace_id, input, &model) != 0) return -1; - int rc = cbm_zova_user_database_publish_model_tx(db, model, 0, out_result); - cbm_zova_publish_model_free(model); - return rc; -} - -static int user_database_publish_model_at_generation( - const char *zova_path, const cbm_zova_publish_model_t *model, - int64_t requested_generation, cbm_zova_workspace_generation_result_t *out_result) { - if (!zova_path || !zova_path[0] || !model || !out_result || requested_generation < 0) - return -1; - memset(out_result, 0, sizeof(*out_result)); - struct timespec phase_started = {0}, phase_finished = {0}; - double writer_guard_ms = 0.0, database_init_ms = 0.0, database_open_ms = 0.0; - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_started); - cbm_zova_writer_guard_t writer_guard = {0}; - if (cbm_zova_writer_guard_acquire(zova_path, &writer_guard) != 0) { - return -1; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_finished); - writer_guard_ms = user_publish_elapsed_ms(&phase_started, &phase_finished); - phase_started = phase_finished; - cbm_zova_db_t zdb; - if (open_or_create_zova(zova_path, &zdb) != 0) { - cbm_zova_writer_guard_release(&writer_guard); - return -1; - } - user_publish_test_metrics.database_open_count++; - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_finished); - database_open_ms = user_publish_elapsed_ms(&phase_started, &phase_finished); - phase_started = phase_finished; - if (user_database_ensure_schema(zdb.db) != 0) { - close_zova(&zdb); - user_publish_test_metrics.database_close_count++; - cbm_zova_writer_guard_release(&writer_guard); - return -1; - } - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_finished); - database_init_ms = user_publish_elapsed_ms(&phase_started, &phase_finished); - struct timespec started = {0}; - struct timespec finished = {0}; - cbm_clock_gettime(CLOCK_MONOTONIC, &started); - struct stat before_db = {0}; - struct stat before_wal = {0}; - char wal_path[ZV_PATH_MAX]; - bool have_before_db = stat(zova_path, &before_db) == 0; - bool have_wal_path = snprintf(wal_path, sizeof(wal_path), "%s-wal", zova_path) < - (int)sizeof(wal_path); - bool have_before_wal = have_wal_path && stat(wal_path, &before_wal) == 0; - uint64_t database_bytes_before = have_before_db ? (uint64_t)before_db.st_size : 0; - uint64_t wal_bytes_before = have_before_wal ? (uint64_t)before_wal.st_size : 0; - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_started); - int rc = status_ok(zova_database_exec(&(zova_database_exec_request){ - .db = zdb.db, - .sql = "PRAGMA journal_mode=WAL;" - "PRAGMA synchronous=NORMAL;" - "PRAGMA cache_size=-65536;"}), - zdb.db, "user_publish.wal"); - if (rc == 0) { - user_publish_test_metrics.transaction_count++; - rc = workspace_registry_begin(zdb.db); - } - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_finished); - double transaction_begin_ms = user_publish_elapsed_ms(&phase_started, &phase_finished); - phase_started = phase_finished; - if (rc == 0) - rc = cbm_zova_user_database_publish_model_tx(zdb.db, model, - requested_generation, out_result); - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_finished); - double transaction_body_ms = user_publish_elapsed_ms(&phase_started, &phase_finished); - phase_started = phase_finished; - if (rc == 0) rc = workspace_registry_commit(zdb.db); - else workspace_registry_rollback(zdb.db); - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_finished); - double transaction_commit_ms = user_publish_elapsed_ms(&phase_started, &phase_finished); - phase_started = phase_finished; - close_zova(&zdb); - user_publish_test_metrics.database_close_count++; - cbm_clock_gettime(CLOCK_MONOTONIC, &phase_finished); - double database_close_ms = user_publish_elapsed_ms(&phase_started, &phase_finished); - cbm_clock_gettime(CLOCK_MONOTONIC, &finished); - out_result->publish_ms = user_publish_elapsed_ms(&started, &finished); - out_result->writer_guard_ms = writer_guard_ms; - out_result->database_init_ms = database_init_ms; - out_result->database_open_ms = database_open_ms; - out_result->transaction_begin_ms = transaction_begin_ms; - out_result->transaction_body_ms = transaction_body_ms; - out_result->transaction_commit_ms = transaction_commit_ms; - out_result->database_close_ms = database_close_ms; - out_result->database_bytes = database_bytes_before; - out_result->wal_bytes_before = wal_bytes_before; - struct stat after_db = {0}; - struct stat after_wal = {0}; - if (stat(zova_path, &after_db) == 0) out_result->database_bytes = (uint64_t)after_db.st_size; - if (have_wal_path && stat(wal_path, &after_wal) == 0) { - out_result->wal_bytes_after = (uint64_t)after_wal.st_size; - } - cbm_zova_writer_guard_release(&writer_guard); - return rc; -} - -static int user_database_publish_workspace_at_generation( - const char *zova_path, const cbm_zova_workspace_generation_input_t *input, - int64_t requested_generation, cbm_zova_workspace_generation_result_t *out_result) { - if (!input) return -1; - char workspace_id[CBM_ZOVA_WORKSPACE_ID_MAX]; - cbm_zova_publish_model_t *model = NULL; - if (cbm_zova_workspace_id_for_root(input->root_path, workspace_id, - sizeof(workspace_id)) != 0 || - cbm_zova_publish_model_build(workspace_id, input, &model) != 0) return -1; - int rc = user_database_publish_model_at_generation(zova_path, model, - requested_generation, out_result); - cbm_zova_publish_model_free(model); - return rc; -} - -int cbm_zova_user_database_publish_model( - const char *zova_path, const cbm_zova_publish_model_t *model, - cbm_zova_workspace_generation_result_t *out_result) { - return user_database_publish_model_at_generation(zova_path, model, 0, out_result); -} - -int cbm_zova_user_database_publish_prepared_view( - const char *zova_path, const cbm_zova_prepared_view_t *view, - cbm_zova_workspace_generation_result_t *out_result) { - return user_database_publish_model_at_generation(zova_path, view, 0, out_result); -} - -int cbm_zova_user_database_publish_delta( - const char *zova_path, const cbm_zova_publish_model_t *after, - const cbm_zova_workspace_delta_t *delta, - cbm_zova_workspace_generation_result_t *out_result) { - if(!zova_path||!zova_path[0]||!after||!delta||!out_result)return -1; - memset(out_result,0,sizeof(*out_result)); - struct timespec phase_started={0},phase_finished={0}; - double writer_guard_ms=0.0,database_init_ms=0.0,database_open_ms=0.0; - cbm_clock_gettime(CLOCK_MONOTONIC,&phase_started); - cbm_zova_writer_guard_t writer_guard={0}; - if(cbm_zova_writer_guard_acquire(zova_path,&writer_guard)!=0)return -1; - cbm_clock_gettime(CLOCK_MONOTONIC,&phase_finished); - writer_guard_ms=user_publish_elapsed_ms(&phase_started,&phase_finished); - phase_started=phase_finished; - cbm_zova_db_t zdb={0}; - if(open_or_create_zova(zova_path,&zdb)!=0){ - cbm_zova_writer_guard_release(&writer_guard);return -1; - } - user_publish_test_metrics.database_open_count++; - cbm_clock_gettime(CLOCK_MONOTONIC,&phase_finished); - database_open_ms=user_publish_elapsed_ms(&phase_started,&phase_finished); - phase_started=phase_finished; - if(user_database_ensure_schema(zdb.db)!=0){ - close_zova(&zdb);user_publish_test_metrics.database_close_count++; - cbm_zova_writer_guard_release(&writer_guard);return -1; - } - cbm_clock_gettime(CLOCK_MONOTONIC,&phase_finished); - database_init_ms=user_publish_elapsed_ms(&phase_started,&phase_finished); - struct timespec started={0},finished={0}; - cbm_clock_gettime(CLOCK_MONOTONIC,&started); - struct stat before_db={0},before_wal={0}; - char wal_path[ZV_PATH_MAX]; - bool have_before_db=stat(zova_path,&before_db)==0; - bool have_wal_path=snprintf(wal_path,sizeof(wal_path),"%s-wal",zova_path)<(int)sizeof(wal_path); - bool have_before_wal=have_wal_path&&stat(wal_path,&before_wal)==0; - cbm_clock_gettime(CLOCK_MONOTONIC,&phase_started); - int rc=status_ok(zova_database_exec(&(zova_database_exec_request){.db=zdb.db, - .sql="PRAGMA journal_mode=WAL;PRAGMA synchronous=NORMAL;PRAGMA cache_size=-65536;"}), - zdb.db,"user_delta.wal"); - if(rc==0){user_publish_test_metrics.transaction_count++;rc=workspace_registry_begin(zdb.db);} - cbm_clock_gettime(CLOCK_MONOTONIC,&phase_finished); - double transaction_begin_ms=user_publish_elapsed_ms(&phase_started,&phase_finished); - phase_started=phase_finished; - user_publish_delta_active=true; - if(rc==0)rc=cbm_zova_user_database_publish_delta_tx(zdb.db,after,delta,out_result); - user_publish_delta_active=false; - cbm_clock_gettime(CLOCK_MONOTONIC,&phase_finished); - double transaction_body_ms=user_publish_elapsed_ms(&phase_started,&phase_finished); - phase_started=phase_finished; - if(rc==0)rc=workspace_registry_commit(zdb.db);else workspace_registry_rollback(zdb.db); - cbm_clock_gettime(CLOCK_MONOTONIC,&phase_finished); - double transaction_commit_ms=user_publish_elapsed_ms(&phase_started,&phase_finished); - phase_started=phase_finished; - close_zova(&zdb); - user_publish_test_metrics.database_close_count++; - cbm_clock_gettime(CLOCK_MONOTONIC,&phase_finished); - double database_close_ms=user_publish_elapsed_ms(&phase_started,&phase_finished); - cbm_clock_gettime(CLOCK_MONOTONIC,&finished); - out_result->publish_ms=user_publish_elapsed_ms(&started,&finished); - out_result->writer_guard_ms=writer_guard_ms; - out_result->database_init_ms=database_init_ms; - out_result->database_open_ms=database_open_ms; - out_result->transaction_begin_ms=transaction_begin_ms; - out_result->transaction_body_ms=transaction_body_ms; - out_result->transaction_commit_ms=transaction_commit_ms; - out_result->database_close_ms=database_close_ms; - out_result->database_bytes=have_before_db?(uint64_t)before_db.st_size:0; - out_result->wal_bytes_before=have_before_wal?(uint64_t)before_wal.st_size:0; - struct stat after_db={0},after_wal={0}; - if(stat(zova_path,&after_db)==0)out_result->database_bytes=(uint64_t)after_db.st_size; - if(have_wal_path&&stat(wal_path,&after_wal)==0)out_result->wal_bytes_after=(uint64_t)after_wal.st_size; - cbm_zova_writer_guard_release(&writer_guard); - return rc; -} - - -int cbm_zova_user_database_publish_workspace( - const char *zova_path, const cbm_zova_workspace_generation_input_t *input, - cbm_zova_workspace_generation_result_t *out_result) { - return user_database_publish_workspace_at_generation(zova_path, input, 0, out_result); -} - -int cbm_zova_user_database_publish_workspace_at_generation( - const char *zova_path, const cbm_zova_workspace_generation_input_t *input, - int64_t requested_generation, cbm_zova_workspace_generation_result_t *out_result) { - if (requested_generation <= 0) return -1; - return user_database_publish_workspace_at_generation(zova_path, input, requested_generation, - out_result); -} - -int cbm_zova_user_database_delete_workspace(const char *zova_path, - const char *workspace_id) { - if (!zova_path || !zova_path[0] || cbm_zova_workspace_id_validate(workspace_id) != 0) { - return -1; - } - cbm_zova_writer_guard_t guard = {0}; - if (cbm_zova_writer_guard_acquire(zova_path, &guard) != 0) return -1; - cbm_zova_db_t zdb = {0}; - if (open_zova(zova_path, false, &zdb) != 0 || workspace_registry_begin(zdb.db) != 0) { - close_zova(&zdb); - cbm_zova_writer_guard_release(&guard); - return -1; - } - int64_t present = 0; - int rc = zova_query_i64(zdb.db, - "SELECT count(*) FROM cbm_workspace_registry WHERE workspace_id=?1", - workspace_id, &present, "user_delete.exists"); - if (rc == 0 && present == 0) { - rc = workspace_registry_commit(zdb.db); - close_zova(&zdb); - cbm_zova_writer_guard_release(&guard); - return rc; - } - int64_t workspace_key = 0; - if (rc == 0 && workspace_key_for_id(zdb.db, workspace_id, &workspace_key) != 0) rc = -1; - - char model[256] = {0}; - int dimensions = 0; - zova_statement *state = NULL; - if (rc == 0 && prepare_zova(zdb.db, - "SELECT model_fingerprint,vector_dimensions FROM cbm_workspace_index_state_v1 " - "WHERE workspace_key=?1", &state, "user_delete.state_prepare") == 0 && - workspace_bind_i64(zdb.db, state, 1, workspace_key, "user_delete.state_bind") == 0) { - zova_step_result step = ZOVA_STEP_DONE; - if (zova_statement_step(&(zova_statement_step_request){.statement=state, - .out_result=&step}) != ZOVA_OK) rc = -1; - if (rc == 0 && step == ZOVA_STEP_ROW) { - zova_text text = {0}; - if (zova_statement_column_text(&(zova_statement_column_text_request){ - .statement=state,.index=0,.out_text=&text}) != ZOVA_OK || - !text.data || text.len >= sizeof(model)) rc = -1; - else { memcpy(model, text.data, text.len); model[text.len] = '\0'; } - zova_text_free(&text); - int64_t value = 0; - if (rc == 0 && zova_statement_column_int64(&(zova_statement_column_int64_request){ - .statement=state,.index=1,.out_value=&value}) != ZOVA_OK) rc = -1; - dimensions = (int)value; - } - } - if (state) (void)zova_statement_finalize(state); - - char graph_name[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX]; - if (rc == 0 && cbm_zova_workspace_graph_name(workspace_id, graph_name, - sizeof(graph_name)) != 0) rc = -1; - if (rc == 0) rc = graph_delete_named_if_exists(zdb.db, graph_name); - if (rc == 0 && model[0] && dimensions > 0) { - char nodes[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX + 96]; - char tokens[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX + 96]; - if (cbm_zova_workspace_node_vector_collection_name(workspace_id, model, dimensions, - nodes, sizeof(nodes)) != 0 || - cbm_zova_workspace_token_vector_collection_name(workspace_id, model, dimensions, - tokens, sizeof(tokens)) != 0 || - collection_delete_if_exists(zdb.db, nodes) != 0 || - collection_delete_if_exists(zdb.db, tokens) != 0) rc = -1; - } - if (rc == 0) rc = user_database_clear_workspace(zdb.db, workspace_id); - const char *tail_tables[] = {"cbm_workspace_health_v1", "cbm_workspace_index_state_v1", - "cbm_generation_integrity_v2", "cbm_database_generation_v1"}; - for (size_t i = 0; rc == 0 && i < sizeof(tail_tables)/sizeof(tail_tables[0]); i++) { - char sql[256]; - snprintf(sql, sizeof(sql), "DELETE FROM %s WHERE workspace_key=?1", tail_tables[i]); - zova_statement *stmt = NULL; - rc = prepare_zova(zdb.db, sql, &stmt, "user_delete.prepare"); - if (rc == 0) rc = workspace_bind_i64(zdb.db, stmt, 1, workspace_key, - "user_delete.bind"); - if (rc == 0) rc = workspace_step_done(zdb.db, stmt, "user_delete.step"); - if (stmt) (void)zova_statement_finalize(stmt); - } - if (rc == 0) { - zova_statement *stmt = NULL; - rc = prepare_zova(zdb.db, - "DELETE FROM cbm_workspace_registry WHERE workspace_id=?1", - &stmt, "user_delete.registry_prepare"); - if (rc == 0) rc = workspace_bind_text(zdb.db, stmt, 1, workspace_id, - "user_delete.registry_bind"); - if (rc == 0) rc = workspace_step_done(zdb.db, stmt, - "user_delete.registry_step"); - if (stmt) (void)zova_statement_finalize(stmt); - } - if (rc == 0) rc = workspace_registry_commit(zdb.db); - else workspace_registry_rollback(zdb.db); - close_zova(&zdb); - cbm_zova_writer_guard_release(&guard); - return rc; -} - -int cbm_zova_delete_workspace_graph(const char *zova_path, const char *workspace_id) { - if (!zova_path || !workspace_name_component_valid(workspace_id)) { - return -1; - } - char graph_name[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX]; - if (cbm_zova_workspace_graph_name(workspace_id, graph_name, sizeof(graph_name)) != 0) { - return -1; - } - cbm_zova_db_t zdb; - if (open_zova(zova_path, false, &zdb) != 0 || workspace_registry_begin(zdb.db) != 0) { - close_zova(&zdb); - return -1; - } - int rc = graph_delete_named_if_exists(zdb.db, graph_name); - if (rc == 0) { - rc = workspace_registry_commit(zdb.db); - } else { - workspace_registry_rollback(zdb.db); - } - close_zova(&zdb); - return rc; -} - -static char *column_text_owned(zova_database *db, zova_statement *stmt, int index) { - zova_text text = {0}; - zova_statement_column_text_request req = { - .statement = stmt, - .index = index, - .out_text = &text, - }; - if (status_ok(zova_statement_column_text(&req), db, "column.text") != 0) { - return NULL; - } - char *copy = zv_strndup(text.data ? text.data : "", text.data ? text.len : 0); - zova_text_free(&text); - return copy; -} - -cbm_zova_vector_session_t *cbm_zova_vector_session_open(const char *zova_path) { - if (!zova_path) { - return NULL; - } - cbm_zova_vector_session_t *session = calloc(1, sizeof(*session)); - if (!session || open_zova(zova_path, true, &session->zdb) != 0) { - free(session); - return NULL; - } - session->generation_valid = sidecar_generation_read_db(session->zdb.db, &session->generation) == 0; - return session; -} - -int cbm_zova_vector_session_search_collection_i8( - cbm_zova_vector_session_t *session, const char *collection, const int8_t *query_values, - int query_count, int vector_dim, int prefilter_limit, int limit, - cbm_zova_vector_hit_t **out, int *out_count) { - if (!session || !collection || !collection[0] || !query_values || query_count <= 0 || - vector_dim <= 0 || prefilter_limit <= 0 || limit <= 0 || !out || !out_count) { - return -1; - } - *out = NULL; - *out_count = 0; - zova_vector_search_results results = {0}; - int rc = 0; - if (query_count == 1) { - zova_vector_values query = { - .element_type = ZOVA_VECTOR_ELEMENT_TYPE_I8, - .i8_values = query_values, - .values_len = (size_t)vector_dim, - }; - rc = status_ok(zova_vector_search(&(zova_vector_search_request){ - .db = session->zdb.db, .collection_name = collection, - .query = query, .limit = (size_t)limit, .out_results = &results}), - session->zdb.db, "workspace_vector.search_i8"); - } else { - rc = status_ok(zova_vector_search_multi_i8(&(zova_vector_search_multi_i8_request){ - .db = session->zdb.db, .collection_name = collection, - .query_values = query_values, - .query_values_len = (size_t)query_count * (size_t)vector_dim, - .query_count = (size_t)query_count, - .dimensions = (size_t)vector_dim, - .candidate_ids = NULL, .candidate_count = 0, - .mode = ZOVA_VECTOR_MULTI_I8_SEARCH_CBM_PREFILTER_MIN_COSINE, - .aggregation = ZOVA_VECTOR_MULTI_I8_AGGREGATION_MIN_COSINE, - .prefilter_query_index = 0, - .prefilter_limit = (size_t)prefilter_limit, - .limit = (size_t)limit, .out_results = &results}), - session->zdb.db, "workspace_vector.search_multi_i8"); - } - cbm_zova_vector_hit_t *items = NULL; - if (rc == 0 && results.len > 0) { - items = calloc(results.len, sizeof(*items)); - if (!items) rc = -1; - } - int count = 0; - for (size_t i = 0; rc == 0 && i < results.len; i++) { - items[i].vector_id = zv_strndup(results.items[i].id, results.items[i].id_len); - if (!items[i].vector_id) { - rc = -1; - break; - } - items[i].score = 1.0 - results.items[i].distance; - count++; - } - zova_vector_search_results_free(&results); - if (rc != 0) { - cbm_zova_vector_hits_free(items, count); - return -1; - } - *out = items; - *out_count = count; - return 0; -} - -int cbm_zova_vector_session_get_workspace_token_i8( - cbm_zova_vector_session_t *session, const char *workspace_id, - const char *model_fingerprint, int vector_dim, const char *token, - int8_t *out_values, size_t out_len, bool *out_found) { - if (!session || !workspace_name_component_valid(workspace_id) || - !workspace_name_component_valid(model_fingerprint) || vector_dim <= 0 || - !token || !token[0] || !out_values || out_len != (size_t)vector_dim || !out_found) { - return -1; - } - *out_found = false; - char collection[ZV_ID_MAX + CBM_ZOVA_WORKSPACE_ID_MAX + 96]; - char vector_id[32]; - if (cbm_zova_workspace_token_vector_collection_name( - workspace_id, model_fingerprint, vector_dim, collection, sizeof(collection)) != 0) { - return -1; - } - zova_statement *statement = NULL; - int rc = prepare_zova(session->zdb.db, - "SELECT m.token_key FROM cbm_token_vector_metadata_v1 m " - "JOIN cbm_workspace_registry w ON w.workspace_key=m.workspace_key " - "WHERE w.workspace_id=?1 AND m.token=?2", &statement, - "token_vector.key_prepare"); - if (rc == 0) rc = workspace_bind_text(session->zdb.db, statement, 1, workspace_id, - "token_vector.key_workspace"); - if (rc == 0) rc = workspace_bind_text(session->zdb.db, statement, 2, token, - "token_vector.key_token"); - zova_step_result step = ZOVA_STEP_DONE; - if (rc == 0 && status_ok(zova_statement_step(&(zova_statement_step_request){ - .statement = statement, .out_result = &step}), session->zdb.db, - "token_vector.key_step") != 0) rc = -1; - if (rc == 0 && step == ZOVA_STEP_DONE) { - (void)zova_statement_finalize(statement); - return 0; - } - int64_t token_key = 0; - if (rc == 0 && (step != ZOVA_STEP_ROW || status_ok(zova_statement_column_int64( - &(zova_statement_column_int64_request){.statement=statement,.index=0, - .out_value=&token_key}), - session->zdb.db, "token_vector.key_value") != 0 || - user_private_key_id(token_key, vector_id) != 0)) rc = -1; - if (statement) (void)zova_statement_finalize(statement); - if (rc != 0) return -1; - zova_vector vector = {0}; - zova_status status = zova_vector_get(&(zova_vector_get_request){ - .db = session->zdb.db, .collection_name = collection, - .vector_id = vector_id, .out_vector = &vector}); - if (status == ZOVA_VECTOR_NOT_FOUND) return 0; - if (status != ZOVA_OK || vector.element_type != ZOVA_VECTOR_ELEMENT_TYPE_I8 || - vector.values_len != out_len || !vector.i8_values) { - if (status == ZOVA_OK) zova_vector_free(&vector); - return -1; - } - memcpy(out_values, vector.i8_values, out_len); - zova_vector_free(&vector); - *out_found = true; - return 0; -} - -void cbm_zova_vector_session_close(cbm_zova_vector_session_t *session) { - if (!session) { - return; - } - close_zova(&session->zdb); - free(session); -} - -int cbm_zova_vector_session_generation(const cbm_zova_vector_session_t *session, - int64_t *out_generation) { - if (!session || !out_generation || !session->generation_valid) { - return -1; - } - *out_generation = session->generation; - return 0; -} - -int cbm_zova_vector_session_has_workspace(const cbm_zova_vector_session_t *session, - const char *workspace_id, bool *out_present) { - if (!session || !workspace_name_component_valid(workspace_id) || !out_present) { - return -1; - } - *out_present = false; - zova_statement *stmt = NULL; - int rc = 0; - const char *sql = NULL; - if (workspace_metadata_table_exists(session->zdb.db, "cbm_nodes_v1")) { - sql = "SELECT 1 FROM cbm_nodes_v1 n JOIN cbm_workspace_registry r " - "ON r.workspace_key=n.workspace_key WHERE r.workspace_id=?1 LIMIT 1"; - } else if (workspace_metadata_table_exists(session->zdb.db, - "cbm_zova_generation_integrity_v1")) { - sql = "SELECT 1 FROM cbm_zova_generation_integrity_v1 " - "WHERE workspace_id=?1 LIMIT 1"; - } else { - return 0; - } - if (prepare_zova(session->zdb.db, sql, &stmt, - "vector_session.workspace_prepare") != 0 || - workspace_bind_text(session->zdb.db, stmt, 1, workspace_id, - "vector_session.workspace_bind") != 0) { - if (stmt) { - (void)zova_statement_finalize(stmt); - stmt = NULL; - } - return -1; - } - zova_step_result step = ZOVA_STEP_DONE; - zova_statement_step_request req = {.statement = stmt, .out_result = &step}; - rc = status_ok(zova_statement_step(&req), session->zdb.db, "vector_session.workspace_step"); - if (stmt) { - (void)zova_statement_finalize(stmt); - stmt = NULL; - } - if (rc != 0 || (step != ZOVA_STEP_ROW && step != ZOVA_STEP_DONE)) { - return -1; - } - *out_present = step == ZOVA_STEP_ROW; - return 0; -} - -typedef struct { - const char *node_id; - int index; -} graph_visit_index_t; - -static int graph_visit_index_compare(const void *left, const void *right) { - const graph_visit_index_t *a = left; - const graph_visit_index_t *b = right; - return strcmp(a->node_id, b->node_id); -} - -static int graph_visit_find(const graph_visit_index_t *items, int count, const char *node_id) { - graph_visit_index_t key = {.node_id = node_id, .index = 0}; - const graph_visit_index_t *found = bsearch(&key, items, (size_t)count, sizeof(*items), - graph_visit_index_compare); - if (!found) { - return -1; - } - if ((found > items && strcmp((found - 1)->node_id, node_id) == 0) || - (found + 1 < items + count && strcmp((found + 1)->node_id, node_id) == 0)) { - return -1; - } - return found->index; -} - -static void graph_record_elapsed(const struct timespec *start, double *out_ms) { - struct timespec end; - cbm_clock_gettime(CLOCK_MONOTONIC, &end); - *out_ms += ((double)(end.tv_sec - start->tv_sec) * 1000.0) + - ((double)(end.tv_nsec - start->tv_nsec) / 1000000.0); -} - -static int graph_hydrate_visits(zova_database *db, const char *workspace_id, - cbm_zova_graph_visit_t *visits, const graph_visit_index_t *index, - int count, cbm_zova_graph_metrics_t *metrics) { - bool *filled = calloc((size_t)count, sizeof(*filled)); - int64_t *node_keys = calloc((size_t)count, sizeof(*node_keys)); - if (!filled || !node_keys) { - free(node_keys); - free(filled); - return -1; - } - int rc = 0; - if (!workspace_metadata_table_exists(db, "cbm_nodes_v1")) { - free(node_keys); - free(filled); - return -1; - } - int64_t workspace_key = 0; - if (workspace_key_for_id(db, workspace_id, &workspace_key) != 0) { - free(node_keys); - free(filled); - return -1; - } - char graph_name[CBM_ZOVA_WORKSPACE_ID_MAX + 32]; - if (cbm_zova_workspace_graph_name(workspace_id, graph_name, sizeof(graph_name)) != 0) { - free(node_keys); - free(filled); - return -1; - } - zova_graph_scan_cursor cursor = {0}; - bool more = true; - while (rc == 0 && more) { - zova_graph_scan_results scan = {0}; - zova_status status = zova_graph_scan(&(zova_graph_scan_request){ - .db = db, .graph_name = graph_name, .node_after = cursor, - .node_limit = 4096, .edge_limit = 0, .out_results = &scan}); - if (status != ZOVA_OK) { - rc = -1; - } else { - for (size_t i = 0; i < scan.nodes_len; i++) { - int visit_index = graph_visit_find(index, count, scan.nodes[i].node_id); - if (visit_index >= 0) node_keys[visit_index] = scan.nodes[i].node_key; - } - more = scan.has_more_nodes != 0; - if (scan.nodes_len > 0) { - const zova_graph_scan_node *last = &scan.nodes[scan.nodes_len - 1]; - cursor.created_order = last->created_order; - cursor.key = last->node_key; - } else if (more) { - rc = -1; - } - } - zova_graph_scan_results_free(&scan); - } - for (int offset = 0; rc == 0 && offset < count; offset += ZV_GRAPH_HYDRATE_BATCH) { - int batch_count = count - offset; - if (batch_count > ZV_GRAPH_HYDRATE_BATCH) { - batch_count = ZV_GRAPH_HYDRATE_BATCH; - } - for (int i = 0; i < batch_count; i++) { - if (node_keys[offset + i] <= 0) { - rc = -1; - break; - } - } - zova_graph_keyed_node_results keyed = {0}; - if (rc == 0 && zova_graph_nodes_get_many_keyed( - &(zova_graph_nodes_get_many_keyed_request){ - .db = db, .graph_name = graph_name, - .node_keys = node_keys + offset, - .key_count = (size_t)batch_count, - .out_results = &keyed}) != ZOVA_OK) { - rc = -1; - } - if (rc == 0 && keyed.len != (size_t)batch_count) rc = -1; - for (int i = 0; rc == 0 && i < batch_count; i++) { - if (!keyed.items[i].found || keyed.items[i].node_key != node_keys[offset + i] || - strcmp(keyed.items[i].node_id, visits[offset + i].node_id) != 0) { - rc = -1; - } - } - zova_graph_keyed_node_results_free(&keyed); - if (rc != 0) break; - - char sql[ZV_SQL_MAX * 8]; - int length = snprintf( - sql, sizeof(sql), - "SELECT n.zova_node_key,n.name,n.qualified_name,f.file_path,n.label,n.start_line," - "n.end_line,n.properties FROM cbm_nodes_v1 n " - "JOIN cbm_files_v1 f ON f.file_key=n.file_key " - "WHERE n.workspace_key=?1 AND n.zova_node_key IN ("); - if (length < 0 || (size_t)length >= sizeof(sql)) { - rc = -1; - break; - } - for (int i = 0; i < batch_count; i++) { - int written = snprintf(sql + length, sizeof(sql) - (size_t)length, "%s?%d", - i == 0 ? "" : ",", i + 2); - if (written < 0 || (size_t)written >= sizeof(sql) - (size_t)length) { - rc = -1; - break; - } - length += written; - } - if (rc != 0 || snprintf(sql + length, sizeof(sql) - (size_t)length, ")") >= - (int)(sizeof(sql) - (size_t)length)) { - rc = -1; - break; - } - zova_statement *stmt = NULL; - struct timespec stage_start; - cbm_clock_gettime(CLOCK_MONOTONIC, &stage_start); - rc = prepare_zova(db, sql, &stmt, "graph.hydrate_prepare"); - if (metrics) { - graph_record_elapsed(&stage_start, &metrics->hydrate_prepare_ms); - } - if (rc == 0) { - rc = workspace_bind_i64(db, stmt, 1, workspace_key, "graph.hydrate_bind_workspace"); - } - for (int i = 0; rc == 0 && i < batch_count; i++) { - rc = workspace_bind_i64(db, stmt, i + 2, node_keys[offset + i], - "graph.hydrate_bind_node"); - } - if (rc == 0) { - cbm_clock_gettime(CLOCK_MONOTONIC, &stage_start); - } - while (rc == 0) { - zova_step_result step = ZOVA_STEP_DONE; - zova_statement_step_request step_req = {.statement = stmt, .out_result = &step}; - if (status_ok(zova_statement_step(&step_req), db, "graph.hydrate_step") != 0) { - rc = -1; - break; - } - if (step == ZOVA_STEP_DONE) { - break; - } - int64_t node_key = 0; - if (status_ok(zova_statement_column_int64( - &(zova_statement_column_int64_request){ - .statement = stmt, .index = 0, .out_value = &node_key}), - db, "graph.hydrate_node_key") != 0) { - rc = -1; - break; - } - int visit_index = -1; - for (int i = 0; i < batch_count; i++) { - if (node_keys[offset + i] == node_key) { - visit_index = offset + i; - break; - } - } - if (visit_index < offset || visit_index >= offset + batch_count || filled[visit_index]) { - rc = -1; - break; - } - cbm_zova_graph_visit_t *visit = &visits[visit_index]; - visit->name = column_text_owned(db, stmt, 1); - visit->qualified_name = column_text_owned(db, stmt, 2); - visit->file_path = column_text_owned(db, stmt, 3); - visit->label = column_text_owned(db, stmt, 4); - visit->properties = column_text_owned(db, stmt, 7); - int64_t start_line = 0; - int64_t end_line = 0; - zova_statement_column_int64_request start_req = { - .statement = stmt, .index = 5, .out_value = &start_line}; - zova_statement_column_int64_request end_req = { - .statement = stmt, .index = 6, .out_value = &end_line}; - if (!visit->name || !visit->qualified_name || !visit->file_path || !visit->label || - !visit->properties || - status_ok(zova_statement_column_int64(&start_req), db, "graph.hydrate_start") != 0 || - status_ok(zova_statement_column_int64(&end_req), db, "graph.hydrate_end") != 0 || - start_line < INT_MIN || start_line > INT_MAX || end_line < INT_MIN || - end_line > INT_MAX) { - rc = -1; - break; - } - visit->start_line = (int)start_line; - visit->end_line = (int)end_line; - filled[visit_index] = true; - } - if (metrics && rc == 0) { - graph_record_elapsed(&stage_start, &metrics->hydrate_step_ms); - } - if (stmt) { - (void)zova_statement_finalize(stmt); - } - } - for (int i = 0; rc == 0 && i < count; i++) { - if (!filled[i]) { - rc = -1; - } - } - free(node_keys); - free(filled); - return rc; -} - -static bool graph_native_profile_enabled(void) { - const char *value = getenv("CBM_ZOVA_GRAPH_PROFILE"); - return value && strcmp(value, "1") == 0; -} - -static void graph_metrics_copy_native_profile(cbm_zova_graph_metrics_t *metrics, - const zova_graph_walk_profile *profile) { - if (!metrics || !profile) { - return; - } - metrics->native_profiled = true; - metrics->native_mutex_wait_ms = profile->mutex_wait_ms; - metrics->native_root_lookup_ms = profile->root_lookup_ms; - metrics->native_adjacency_prepare_ms = profile->adjacency_prepare_ms; - metrics->native_adjacency_execute_ms = profile->adjacency_execute_ms; - metrics->native_bfs_bookkeeping_allocation_ms = profile->bfs_bookkeeping_allocation_ms; - metrics->native_c_abi_result_export_ms = profile->c_abi_result_export_ms; - metrics->native_total_profiled_ms = profile->total_profiled_ms; - metrics->frontier_expansions = profile->frontier_expansions; - metrics->adjacency_query_binds = profile->adjacency_query_binds; - metrics->adjacency_rows_stepped = profile->adjacency_rows_stepped; - metrics->native_result_count = profile->result_count; -} - -cbm_zova_graph_session_t *cbm_zova_graph_session_open(const char *zova_path) { - if (!zova_path) { - return NULL; - } - cbm_zova_graph_session_t *session = calloc(1, sizeof(*session)); - if (!session || open_zova(zova_path, true, &session->zdb) != 0) { - free(session); - return NULL; - } - session->generation_valid = sidecar_generation_read_db(session->zdb.db, &session->generation) == 0; - return session; -} - -void cbm_zova_graph_session_close(cbm_zova_graph_session_t *session) { - if (!session) { - return; - } - close_zova(&session->zdb); - free(session); -} - -int cbm_zova_graph_session_generation(const cbm_zova_graph_session_t *session, - int64_t *out_generation) { - if (!session || !out_generation || !session->generation_valid) { - return -1; - } - *out_generation = session->generation; - return 0; -} - -int cbm_zova_graph_session_walk_calls( - cbm_zova_graph_session_t *session, const char *workspace_id, const char *graph_name, - const char *start_node_id, const char *direction, int max_depth, int max_results, - cbm_zova_graph_visit_t **out_visits, int *out_count, cbm_zova_graph_metrics_t *out_metrics) { - if (out_visits) { - *out_visits = NULL; - } - if (out_count) { - *out_count = 0; - } - if (out_metrics) { - memset(out_metrics, 0, sizeof(*out_metrics)); - out_metrics->fallback = true; - } - if (!session || !workspace_name_component_valid(workspace_id) || !graph_name || !graph_name[0] || - !start_node_id || !start_node_id[0] || !direction || max_depth < 0 || max_results <= 0 || - !out_visits || !out_count || max_results == INT_MAX) { - return -1; - } - int zova_direction = strcmp(direction, "inbound") == 0 - ? ZOVA_GRAPH_NEIGHBOR_INCOMING - : strcmp(direction, "outbound") == 0 ? ZOVA_GRAPH_NEIGHBOR_OUTGOING : -1; - if (zova_direction < 0) { - return -1; - } - zova_graph_walk_results results = {0}; - bool profiled = graph_native_profile_enabled(); - zova_graph_walk_profile profile = {0}; - zova_graph_walk_direction_request request = { - .db = session->zdb.db, - .graph_name = graph_name, - .start_node_id = start_node_id, - .direction = zova_direction, - .edge_type = "CALLS", - .max_depth = (uint32_t)max_depth, - .limit = (size_t)max_results + 1, - .out_results = &results, - }; - struct timespec stage_start; - cbm_clock_gettime(CLOCK_MONOTONIC, &stage_start); - int rc; - if (profiled) { - zova_graph_walk_direction_profiled_request profiled_request = { - .db = request.db, - .graph_name = request.graph_name, - .start_node_id = request.start_node_id, - .direction = request.direction, - .edge_type = request.edge_type, - .max_depth = request.max_depth, - .limit = request.limit, - .out_results = request.out_results, - .out_profile = &profile, - }; - rc = status_ok(zova_graph_walk_direction_profiled(&profiled_request), session->zdb.db, - "graph.walk_calls_profiled"); - if (out_metrics) { - graph_metrics_copy_native_profile(out_metrics, &profile); - } - } else { - rc = status_ok(zova_graph_walk_direction(&request), session->zdb.db, "graph.walk_calls"); - } - if (out_metrics) { - graph_record_elapsed(&stage_start, &out_metrics->walk_ms); - } - if (rc != 0 || results.len == 0 || !results.items || - strcmp(results.items[0].node_id, start_node_id) != 0 || results.items[0].depth != 0 || - results.len > (size_t)max_results + 1 || results.len - 1 > (size_t)INT_MAX) { - zova_graph_walk_results_free(&results); - return -1; - } - int count = (int)(results.len - 1); - cbm_zova_graph_visit_t *visits = calloc((size_t)count, sizeof(*visits)); - graph_visit_index_t *index = calloc((size_t)count, sizeof(*index)); - cbm_clock_gettime(CLOCK_MONOTONIC, &stage_start); - if ((count > 0 && (!visits || !index))) { - rc = -1; - } - for (int i = 0; rc == 0 && i < count; i++) { - const zova_graph_walk_result *item = &results.items[i + 1]; - if (!item->node_id || !item->node_id[0] || item->depth == 0 || item->depth > (uint32_t)INT_MAX) { - rc = -1; - break; - } - visits[i].node_id = zv_strndup(item->node_id, strlen(item->node_id)); - visits[i].hop = (int)item->depth; - index[i] = (graph_visit_index_t){.node_id = visits[i].node_id, .index = i}; - if (!visits[i].node_id) { - rc = -1; - } - } - if (rc == 0 && count > 1) { - qsort(index, (size_t)count, sizeof(*index), graph_visit_index_compare); - for (int i = 1; i < count; i++) { - if (strcmp(index[i - 1].node_id, index[i].node_id) == 0) { - rc = -1; - break; - } - } - } - if (out_metrics) { - graph_record_elapsed(&stage_start, &out_metrics->result_build_ms); - } - if (rc == 0 && count > 0) { - rc = graph_hydrate_visits(session->zdb.db, workspace_id, visits, index, count, out_metrics); - } - zova_graph_walk_results_free(&results); - free(index); - if (rc != 0) { - cbm_zova_graph_visits_free(visits, count); - return -1; - } - *out_visits = visits; - *out_count = count; - if (out_metrics) { - out_metrics->walk_count = 1; - out_metrics->fallback = false; - } - return 0; -} - -#endif diff --git a/src/zova/cbm_zova.zig b/src/zova/cbm_zova.zig new file mode 100644 index 00000000..7546219d --- /dev/null +++ b/src/zova/cbm_zova.zig @@ -0,0 +1,5033 @@ +//! cbm_zova.zig — The user-local cbm.zova database: workspace ids and names, +//! the SQL schema, publication of a workspace generation (full or delta), the +//! workspace registry, and the vector and graph query sessions. +//! +//! Implements src/zova/cbm_zova.h. Without Zova every function that needs the +//! database clears its outputs and fails; the id and name helpers still work. + +const std = @import("std"); +const c = @import("c"); +const interop = @import("../foundation/interop.zig"); + +const Str = interop.Str; +const CStr = interop.CStr; +const span = std.mem.span; + +const with_zova = c.CBM_WITH_ZOVA != 0; + +// Zova SDK types. They exist only with Zova; nothing reaches them without it. +const Db = if (with_zova) c.zova_database else anyopaque; +const Stmt = if (with_zova) c.zova_statement else anyopaque; + +const VectorHit = c.cbm_zova_vector_hit_t; +const GraphVisit = c.cbm_zova_graph_visit_t; +const GraphAdjacency = c.cbm_zova_graph_adjacency_t; +const GraphMetrics = c.cbm_zova_graph_metrics_t; +const SqlCapabilities = c.cbm_zova_sql_capabilities_t; +const FormatStatus = c.cbm_zova_database_format_status_t; +const RepackPhase = c.cbm_zova_repack_phase_t; +const GenerationInput = c.cbm_zova_workspace_generation_input_t; +const GenerationResult = c.cbm_zova_workspace_generation_result_t; +const PublishModel = c.cbm_zova_publish_model_t; +const PreparedView = c.cbm_zova_prepared_view_t; +const WorkspaceDelta = c.cbm_zova_workspace_delta_t; +const PublishTestMetrics = c.cbm_zova_publish_test_metrics_t; +const StatementPhaseMetrics = c.cbm_zova_statement_phase_metrics_t; +const DeltaMetrics = c.cbm_zova_workspace_delta_metrics_t; + +const node_fts_insert_trigger_sql = + "CREATE TRIGGER IF NOT EXISTS cbm_nodes_v1_fts_ai AFTER INSERT ON cbm_nodes_v1 BEGIN " ++ + "INSERT INTO cbm_nodes_fts_v1(rowid,name,qualified_name,file_path,label) " ++ + "SELECT NEW.zova_node_key,cbm_camel_split(NEW.name),NEW.qualified_name,f.file_path,NEW.label " ++ + "FROM cbm_files_v1 f WHERE f.file_key=NEW.file_key; END;"; + +const ZV_PATH_MAX: usize = 1024; +const ZV_BATCH: usize = 512; +const ZV_ID_MAX: usize = 32; +const ZV_SQL_MAX: usize = 512; + +const ID_MAX: usize = c.CBM_ZOVA_WORKSPACE_ID_MAX; +const DIGEST_HEX_SIZE: usize = c.CBM_ZOVA_DIGEST_HEX_SIZE; +const SCHEMA_VERSION: i64 = c.CBM_ZOVA_DATABASE_SCHEMA_VERSION; +const GRAPH_NAME_CAP: usize = ZV_ID_MAX + ID_MAX; +const COLLECTION_NAME_CAP: usize = ZV_ID_MAX + ID_MAX + 96; +const INT_MAX: c_int = std.math.maxInt(c_int); + +var user_publish_test_metrics: PublishTestMetrics = std.mem.zeroes(PublishTestMetrics); +threadlocal var user_publish_delta_active: bool = false; + +fn Span(comptime P: type) type { + const info = @typeInfo(P).pointer; + return if (info.attrs.@"const") []const info.child else []info.child; +} + +/// The items of a (C pointer, count) array; empty for NULL or count <= 0. +fn many(items: anytype, count: anytype) Span(@TypeOf(items)) { + if (items == null or count <= 0) return &.{}; + return items[0..@intCast(count)]; +} + +/// The string when it is neither NULL nor empty. +fn nonEmpty(text: ?Str) ?Str { + const value = text orelse return null; + return if (value[0] != 0) value else null; +} + +fn cast(comptime T: type, p: ?*const anyopaque) *const T { + return @ptrCast(@alignCast(p)); +} + +/// The caller's (pointer, size) output buffer; NULL when it has no room. +fn outBuf(out: ?[*]u8, size: usize) ?[]u8 { + const buf = out orelse return null; + return if (size != 0) buf[0..size] else null; +} + +/// snprintf into `out`: 0 when the whole text fit, -1 when it was cut. +fn formatInto(out: []u8, comptime fmt: []const u8, args: anytype) c_int { + return if (interop.bprint(out, fmt, args) < out.len) 0 else -1; +} + +/// The NUL-terminated text at the start of `buf`. +fn bufZ(buf: anytype) Str { + return @ptrCast(buf); +} + +fn userPublishMetricRow( + maybe_metrics: ?*StatementPhaseMetrics, + bind_i64_calls: u64, + bind_text_calls: u64, + bind_double_calls: u64, + cleared_bindings: bool, +) void { + const metrics = maybe_metrics orelse return; + metrics.rows += 1; + metrics.bind_i64_calls += bind_i64_calls; + metrics.bind_text_calls += bind_text_calls; + metrics.bind_double_calls += bind_double_calls; + metrics.step_calls += 1; + metrics.reset_calls += 1; + if (cleared_bindings) metrics.clear_bindings_calls += 1; +} + +pub export fn cbm_zova_publish_test_metrics_reset() void { + user_publish_test_metrics = std.mem.zeroes(PublishTestMetrics); +} + +pub export fn cbm_zova_publish_test_metrics_get(out_metrics: ?*PublishTestMetrics) void { + if (out_metrics) |out| out.* = user_publish_test_metrics; +} + +pub export fn cbm_zova_build_enabled() bool { + return with_zova; +} + +// ── Workspace ids and names ───────────────────────────────────── + +/// Copies `root_path` into `out` with '/' separators and no trailing '/'. +/// NULL for a NULL, empty or too long path. +fn workspaceNormalizeRoot(root_path: ?Str, out: *[ZV_PATH_MAX]u8) ?[:0]const u8 { + const path = nonEmpty(root_path) orelse return null; + if (interop.bprint(out, "{s}", .{span(path)}) >= ZV_PATH_MAX) return null; + _ = c.cbm_normalize_path_sep(out); + var len = c.strlen(out); + while (len > 1 and out[len - 1] == '/') { + len -= 1; + out[len] = 0; + } + return out[0..len :0]; +} + +fn workspaceIdFromRoot(root: []const u8, out: []u8) c_int { + var digest: [c.CBM_SHA256_HEX_LEN + 1]u8 = undefined; + c.cbm_sha256_hex(root.ptr, root.len, &digest); + return formatInto(out, "w1_{s}", .{digest[0..32]}); +} + +pub export fn cbm_zova_workspace_id_for_root(root_path: ?Str, out_workspace_id: ?[*]u8, out_workspace_id_size: usize) c_int { + var root_buf: [ZV_PATH_MAX]u8 = undefined; + const out = outBuf(out_workspace_id, out_workspace_id_size) orelse return -1; + const root = workspaceNormalizeRoot(root_path, &root_buf) orelse return -1; + return workspaceIdFromRoot(root, out); +} + +pub export fn cbm_zova_workspace_registry_path(out: ?[*]u8, out_size: usize) c_int { + const cache_dir: CStr = c.cbm_resolve_cache_dir(); + const buf = outBuf(out, out_size) orelse return -1; + if (cache_dir == null) return -1; + return formatInto(buf, "{s}/cbm.zova", .{span(cache_dir)}); +} + +/// One or more of [A-Za-z0-9_]. +fn workspaceNameComponentValid(value: ?Str) bool { + const text = nonEmpty(value) orelse return false; + for (span(text)) |ch| { + if (!std.ascii.isAlphanumeric(ch) and ch != '_') return false; + } + return true; +} + +pub export fn cbm_zova_workspace_id_validate(workspace_id: ?Str) c_int { + return if (workspaceNameComponentValid(workspace_id)) 0 else -1; +} + +/// The first 16 bytes, in hex, of SHA-256 over each part followed by a NUL. +fn partsDigestHex(parts: []const Str) [32]u8 { + var hash: c.cbm_sha256_ctx = undefined; + var digest: [c.CBM_SHA256_DIGEST_LEN]u8 = undefined; + c.cbm_sha256_init(&hash); + for (parts) |part| { + c.cbm_sha256_update(&hash, part, c.strlen(part)); + c.cbm_sha256_update(&hash, "\x00", 1); + } + c.cbm_sha256_final(&hash, &digest); + return std.fmt.bytesToHex(digest[0..16].*, .lower); +} + +pub export fn cbm_zova_workspace_token_id_v1(workspace_id: ?Str, token: ?Str, out: ?[*]u8, out_size: usize) c_int { + if (!workspaceNameComponentValid(workspace_id)) return -1; + const text = token orelse return -1; + const buf = outBuf(out, out_size) orelse return -1; + const hex = partsDigestHex(&.{text}); + return formatInto(buf, "t:v1:{s}:{s}", .{ span(workspace_id.?), &hex }); +} + +pub export fn cbm_zova_workspace_graph_name(workspace_id: ?Str, out: ?[*]u8, out_size: usize) c_int { + if (!workspaceNameComponentValid(workspace_id)) return -1; + const buf = outBuf(out, out_size) orelse return -1; + return formatInto(buf, "cbm_graph_{s}", .{span(workspace_id.?)}); +} + +fn vectorCollectionName(comptime prefix: []const u8, workspace_id: ?Str, model_fingerprint: ?Str, dimensions: c_int, out: ?[*]u8, out_size: usize) c_int { + if (!workspaceNameComponentValid(workspace_id) or !workspaceNameComponentValid(model_fingerprint) or dimensions <= 0) return -1; + const buf = outBuf(out, out_size) orelse return -1; + return formatInto(buf, prefix ++ "{s}_{s}_d{d}", .{ span(workspace_id.?), span(model_fingerprint.?), dimensions }); +} + +pub export fn cbm_zova_workspace_node_vector_collection_name(workspace_id: ?Str, model_fingerprint: ?Str, dimensions: c_int, out: ?[*]u8, out_size: usize) c_int { + return vectorCollectionName("cbm_nodes_i8_", workspace_id, model_fingerprint, dimensions, out, out_size); +} + +pub export fn cbm_zova_workspace_token_vector_collection_name(workspace_id: ?Str, model_fingerprint: ?Str, dimensions: c_int, out: ?[*]u8, out_size: usize) c_int { + return vectorCollectionName("cbm_tokens_i8_", workspace_id, model_fingerprint, dimensions, out, out_size); +} + +pub export fn cbm_zova_workspace_node_id_v1( + workspace_id: ?Str, + node_kind: ?Str, + relative_path: ?Str, + qualified_name: ?Str, + semantic_discriminator: ?Str, + out: ?[*]u8, + out_size: usize, +) c_int { + if (!workspaceNameComponentValid(workspace_id)) return -1; + const kind = node_kind orelse return -1; + const path = relative_path orelse return -1; + const name = qualified_name orelse return -1; + const discriminator = semantic_discriminator orelse return -1; + const buf = outBuf(out, out_size) orelse return -1; + const hex = partsDigestHex(&.{ kind, path, name, discriminator }); + return formatInto(buf, "n:v1:{s}:{s}", .{ span(workspace_id.?), &hex }); +} + +pub export fn cbm_zova_workspace_node_id_v2( + workspace_id: ?Str, + node_kind: ?Str, + relative_path: ?Str, + qualified_name: ?Str, + semantic_discriminator: ?Str, + out: ?[*]u8, + out_size: usize, +) c_int { + if (!workspaceNameComponentValid(workspace_id)) return -1; + const kind = nonEmpty(node_kind) orelse return -1; + const path = relative_path orelse return -1; + const name = qualified_name orelse return -1; + const discriminator = nonEmpty(semantic_discriminator) orelse return -1; + const buf = outBuf(out, out_size) orelse return -1; + const hex = partsDigestHex(&.{ kind, path, name, discriminator }); + return formatInto(buf, "n:v2:{s}:{s}", .{ span(workspace_id.?), &hex }); +} + +pub export fn cbm_zova_vector_hits_free(items: [*c]VectorHit, count: c_int) void { + for (many(items, count)) |*item| c.free(item.vector_id); + c.free(items); +} + +pub export fn cbm_zova_graph_visits_free(visits: [*c]GraphVisit, count: c_int) void { + for (many(visits, count)) |*visit| { + c.free(visit.node_id); + c.free(visit.name); + c.free(visit.qualified_name); + c.free(visit.file_path); + c.free(visit.label); + c.free(visit.properties); + } + c.free(visits); +} + +pub export fn cbm_zova_graph_adjacency_free(items: [*c]GraphAdjacency, count: c_int) void { + for (many(items, count)) |*item| { + c.free(item.source_node_id); + c.free(item.target_node_id); + c.free(item.edge_type); + c.free(item.properties); + } + c.free(items); +} + +// ── Database handles ──────────────────────────────────────────── + +const ZovaDb = extern struct { + db: ?*Db = null, +}; + +const VectorSession = extern struct { + zdb: ZovaDb, + generation: i64, + generation_valid: bool, +}; + +const GraphSession = extern struct { + zdb: ZovaDb, + generation: i64, + generation_valid: bool, +}; + +const ZV_CAMEL_SPLIT_BUF: usize = 2048; +const ZV_CAMEL_BUF_GUARD: usize = 2; +const ZV_GRAPH_HYDRATE_BATCH: usize = 128; + +const ZV_DENOM_EPS_D: f64 = 1e-10; + +/// A malloc'd NUL-terminated copy of data[0..len]; NULL on OOM. +fn zvStrndup(data: [*c]const u8, len: usize) [*c]u8 { + const copy = interop.mallocN(u8, len + 1); + if (copy == null) return null; + if (len > 0) @memcpy(copy[0..len], data[0..len]); + copy[len] = 0; + return copy; +} + +/// A malloc'd copy of a NUL-terminated string; NULL on OOM. +fn zvStrdup(text: CStr) [*c]u8 { + return zvStrndup(text, c.strlen(text)); +} + +// ── SQL functions ─────────────────────────────────────────────── + +fn zvCamelShouldSplit(input: []const u8, i: usize) bool { + if (i == 0) return false; + const curr = input[i]; + const prev = input[i - 1]; + const next: u8 = if (i + 1 < input.len) input[i + 1] else 0; + if (std.ascii.isUpper(curr) and std.ascii.isLower(prev)) return true; + return std.ascii.isUpper(curr) and std.ascii.isUpper(prev) and std.ascii.isLower(next); +} + +fn sqlResultInt(out: *c.zova_sql_result, value: i64) void { + out.* = .{ .result_type = c.ZOVA_SQL_RESULT_INTEGER, .int64_value = value }; +} + +fn sqlResultDouble(out: *c.zova_sql_result, value: f64) void { + out.* = .{ .result_type = c.ZOVA_SQL_RESULT_FLOAT, .double_value = value }; +} + +fn sqlResultText(out: *c.zova_sql_result, text: []const u8) void { + out.* = .{ .result_type = c.ZOVA_SQL_RESULT_TEXT, .data = text.ptr, .data_len = text.len }; +} + +fn sqlResultError(out: *c.zova_sql_result, message: [:0]const u8) void { + out.* = .{ .result_type = c.ZOVA_SQL_RESULT_ERROR, .error_message = message.ptr, .error_message_len = message.len }; +} + +fn sqlArg(call: ?*const c.zova_sql_function_call, index: usize) ?*const c.zova_sql_value { + const invocation = call orelse return null; + if (invocation.argv == null or index >= invocation.argc) return null; + return &invocation.argv[index]; +} + +/// The bytes of a TEXT or BLOB argument; empty when it carries none. +fn sqlValueBytes(value: *const c.zova_sql_value) []const u8 { + const data: [*]const u8 = @ptrCast(value.data orelse return ""); + return data[0..value.data_len]; +} + +/// A malloc'd NUL-terminated copy of a TEXT argument; NULL for anything else. +fn sqlTextCopy(maybe_value: ?*const c.zova_sql_value) [*c]u8 { + const value = maybe_value orelse return null; + if (value.value_type != c.ZOVA_SQL_VALUE_TEXT) return null; + if (value.data == null and value.data_len != 0) return null; + const text = sqlValueBytes(value); + return zvStrndup(text.ptr, text.len); +} + +fn zovaSqlCosineI8(_: ?*anyopaque, call: [*c]const c.zova_sql_function_call, out_result: [*c]c.zova_sql_result) callconv(.c) void { + const out: *c.zova_sql_result = out_result; + const a_value = sqlArg(call, 0); + const b_value = sqlArg(call, 1); + if (a_value == null or b_value == null or a_value.?.value_type != c.ZOVA_SQL_VALUE_BLOB or + b_value.?.value_type != c.ZOVA_SQL_VALUE_BLOB or a_value.?.data_len != b_value.?.data_len or + a_value.?.data_len == 0 or a_value.?.data == null or b_value.?.data == null) + { + return sqlResultDouble(out, 0.0); + } + + const a: []const i8 = @ptrCast(sqlValueBytes(a_value.?)); + const b: []const i8 = @ptrCast(sqlValueBytes(b_value.?)); + var dot: i64 = 0; + var mag_a: i64 = 0; + var mag_b: i64 = 0; + for (a, b) |x, y| { + dot += @as(i64, x) * @as(i64, y); + mag_a += @as(i64, x) * @as(i64, x); + mag_b += @as(i64, y) * @as(i64, y); + } + + const denom = @sqrt(@as(f64, @floatFromInt(mag_a))) * @sqrt(@as(f64, @floatFromInt(mag_b))); + sqlResultDouble(out, if (denom > ZV_DENOM_EPS_D) @as(f64, @floatFromInt(dot)) / denom else 0.0); +} + +// Zova copies the result bytes after the callback returns, so they must not +// live in its stack frame. Callbacks do not re-enter, so one buffer per +// thread is enough. +threadlocal var camel_split_buf: [ZV_CAMEL_SPLIT_BUF]u8 = undefined; + +fn zovaSqlCamelSplit(_: ?*anyopaque, call: [*c]const c.zova_sql_function_call, out_result: [*c]c.zova_sql_result) callconv(.c) void { + const out: *c.zova_sql_result = out_result; + const value = sqlArg(call, 0) orelse return sqlResultText(out, ""); + if (value.value_type != c.ZOVA_SQL_VALUE_TEXT) return sqlResultText(out, ""); + const input = sqlValueBytes(value); + if (input.len == 0) return sqlResultText(out, ""); + const buf = &camel_split_buf; + if (input.len + 1 >= buf.len) return sqlResultText(out, input); + @memcpy(buf[0..input.len], input); + var len = input.len; + buf[len] = ' '; + len += 1; + for (input, 0..) |ch, i| { + if (len >= buf.len - ZV_CAMEL_BUF_GUARD) break; + if (zvCamelShouldSplit(input, i)) { + buf[len] = ' '; + len += 1; + } + buf[len] = ch; + len += 1; + } + buf[len] = 0; + sqlResultText(out, buf[0..len]); +} + +fn zovaSqlRegexpImpl(call: ?*const c.zova_sql_function_call, out: *c.zova_sql_result, flags: c_int) void { + const pattern = sqlTextCopy(sqlArg(call, 0)); + defer c.free(pattern); + const text = sqlTextCopy(sqlArg(call, 1)); + defer c.free(text); + if (pattern == null or text == null) return sqlResultInt(out, 0); + + var re: c.cbm_regex_t = undefined; + if (c.cbm_regcomp(&re, pattern, flags) != 0) return sqlResultError(out, "invalid regex"); + const matched = c.cbm_regexec(&re, text, 0, null, 0) == 0; + c.cbm_regfree(&re); + sqlResultInt(out, @intFromBool(matched)); +} + +fn zovaSqlRegexp(_: ?*anyopaque, call: [*c]const c.zova_sql_function_call, out_result: [*c]c.zova_sql_result) callconv(.c) void { + zovaSqlRegexpImpl(call, out_result, c.CBM_REG_EXTENDED | c.CBM_REG_NOSUB); +} + +fn zovaSqlIregexp(_: ?*anyopaque, call: [*c]const c.zova_sql_function_call, out_result: [*c]c.zova_sql_result) callconv(.c) void { + zovaSqlRegexpImpl(call, out_result, c.CBM_REG_EXTENDED | c.CBM_REG_NOSUB | c.CBM_REG_ICASE); +} + +/// True for ZOVA_OK. Anything else is logged with `phase` and the database's +/// last error message. +fn statusOk(status: c.zova_status, db: ?*Db, phase: ?Str) bool { + if (status == c.ZOVA_OK) return true; + const msg: CStr = if (db != null) c.zova_database_last_error_message(db) else null; + interop.log(c.CBM_LOG_ERROR, "zova.err", .{ + "phase", phase orelse "unknown", + "status", c.zova_status_name(status), + "msg", if (msg != null) msg else @as(CStr, ""), + }); + return false; +} + +fn registerZovaSqlFunction(db: ?*Db, name: Str, arity: c_int, callback: c.zova_sql_scalar_callback) bool { + const status = c.zova_database_register_function(&c.zova_sql_function_register_request{ + .db = db, + .name = name, + .arity = arity, + .flags = c.ZOVA_SQL_FUNCTION_DETERMINISTIC | c.ZOVA_SQL_FUNCTION_INNOCUOUS, + .user_data = null, + .callback = callback, + .destroy = null, + }); + // Pinned Zova builds report duplicate registration as INVALID_ARGUMENT. + // Treat that one case as success so callers can safely establish the + // callback set on handles opened by another CBM helper. + if (status == c.ZOVA_INVALID_ARGUMENT) return true; + return statusOk(status, db, name); +} + +fn registerSqlFunctions(db: ?*Db) bool { + if (db == null) return false; + return registerZovaSqlFunction(db, "cbm_cosine_i8", 2, zovaSqlCosineI8) and + registerZovaSqlFunction(db, "cbm_camel_split", 1, zovaSqlCamelSplit) and + registerZovaSqlFunction(db, "regexp", 2, zovaSqlRegexp) and + registerZovaSqlFunction(db, "iregexp", 2, zovaSqlIregexp); +} + +// ── Zova SDK calls ────────────────────────────────────────────── + +fn execSql(db: ?*Db, sql: Str) c.zova_status { + return c.zova_database_exec(&c.zova_database_exec_request{ .db = db, .sql = sql }); +} + +fn closeZova(zdb: *ZovaDb) void { + if (zdb.db) |db| { + _ = c.zova_database_close(db); + zdb.db = null; + } +} + +/// The prepared statement; NULL after logging the failure under `phase`. +fn prepareZova(db: ?*Db, sql: Str, phase: Str) ?*Stmt { + var stmt: ?*Stmt = null; + const status = c.zova_database_prepare(&c.zova_database_prepare_request{ .db = db, .sql = sql, .out_statement = &stmt }); + if (statusOk(status, db, phase)) return stmt; + finalize(stmt); + return null; +} + +fn finalize(stmt: ?*Stmt) void { + if (stmt) |open| _ = c.zova_statement_finalize(open); +} + +fn stepStatement(stmt: ?*Stmt, out: *c.zova_step_result) c.zova_status { + return c.zova_statement_step(&c.zova_statement_step_request{ .statement = stmt, .out_result = out }); +} + +fn columnI64(stmt: ?*Stmt, index: c_int, out: *i64) c.zova_status { + return c.zova_statement_column_int64(&c.zova_statement_column_int64_request{ .statement = stmt, .index = index, .out_value = out }); +} + +fn workspaceBindText(db: ?*Db, stmt: ?*Stmt, index: c_int, value: CStr, phase: Str) bool { + return statusOk(c.zova_statement_bind_text(&c.zova_statement_bind_text_request{ + .statement = stmt, + .index = index, + .data = value, + .len = c.strlen(value), + }), db, phase); +} + +fn workspaceBindI64(db: ?*Db, stmt: ?*Stmt, index: c_int, value: i64, phase: Str) bool { + return statusOk(c.zova_statement_bind_int64(&c.zova_statement_bind_int64_request{ + .statement = stmt, + .index = index, + .value = value, + }), db, phase); +} + +/// Steps once and expects the statement to finish without a row. +fn workspaceStepDone(db: ?*Db, stmt: ?*Stmt, phase: Str) bool { + var result: c.zova_step_result = c.ZOVA_STEP_DONE; + return statusOk(stepStatement(stmt, &result), db, phase) and result == c.ZOVA_STEP_DONE; +} + +/// Steps to the statement's row and reads int64 column 0, logging a failed +/// step or read under its phase. NULL when there is no row. +fn stepRowI64Logged(db: ?*Db, stmt: ?*Stmt, step_phase: Str, column_phase: Str) ?i64 { + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + var value: i64 = 0; + if (!statusOk(stepStatement(stmt, &step), db, step_phase) or step != c.ZOVA_STEP_ROW) return null; + return if (statusOk(columnI64(stmt, 0, &value), db, column_phase)) value else null; +} + +/// stepRowI64Logged without the logging. +fn stepRowI64(stmt: ?*Stmt) ?i64 { + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + var value: i64 = 0; + if (stepStatement(stmt, &step) != c.ZOVA_OK or step != c.ZOVA_STEP_ROW) return null; + return if (columnI64(stmt, 0, &value) == c.ZOVA_OK) value else null; +} + +/// Copies text column `index` into `out` with a NUL. False when the read +/// fails, the column is NULL or the text does not fit. +fn columnTextInto(db: ?*Db, stmt: ?*Stmt, index: c_int, out: []u8, phase: Str) bool { + var text: c.zova_text = .{}; + defer c.zova_text_free(&text); + if (!statusOk(c.zova_statement_column_text(&c.zova_statement_column_text_request{ + .statement = stmt, + .index = index, + .out_text = &text, + }), db, phase)) return false; + if (text.data == null or text.len + 1 > out.len) return false; + @memcpy(out[0..text.len], text.data[0..text.len]); + out[text.len] = 0; + return true; +} + +/// The CBM_ZOVA_TEST_FAIL_PHASE fault injection hook. +fn testFailPhase(phase: []const u8) bool { + const fault: CStr = c.getenv("CBM_ZOVA_TEST_FAIL_PHASE"); + return fault != null and std.mem.eql(u8, span(fault), phase); +} + +fn workspaceMetadataTableExists(db: ?*Db, table: Str) bool { + const stmt = prepareZova(db, "SELECT 1 FROM sqlite_master WHERE type='table' AND " ++ + "name=?1 LIMIT 1", "workspace_metadata.probe") orelse return false; + defer finalize(stmt); + if (!workspaceBindText(db, stmt, 1, table, "workspace_metadata.probe_bind")) return false; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + return statusOk(stepStatement(stmt, &step), db, "workspace_metadata.probe_step") and step == c.ZOVA_STEP_ROW; +} + +// ── Graph session: adjacency and degree ───────────────────────── + +const AdjacencyList = struct { + items: [*c]GraphAdjacency, + count: c_int, + capacity: c_int, +}; + +const GraphAdjacencyPayloadContext = struct { + list: *AdjacencyList, + max_results: c_int, + source: CStr, + target: CStr, + edge_type: CStr, + edge_type_len: usize, +}; + +fn graphAdjacencyAppendPayload(record: [*c]const c.cbm_zova_edge_payload_record_t, context_ptr: ?*anyopaque) callconv(.c) c_int { + if (record == null or context_ptr == null) return -1; + const context = cast(GraphAdjacencyPayloadContext, context_ptr); + const list = context.list; + if (list.count >= context.max_results) return 0; + if (list.count == list.capacity) { + if (list.capacity > @divTrunc(INT_MAX, 2)) return -1; + const grown_capacity = list.capacity * 2; + const grown = interop.reallocN(GraphAdjacency, list.items, @intCast(grown_capacity)); + if (grown == null) return -1; + list.items = grown; + @memset(grown[@intCast(list.capacity)..@intCast(grown_capacity)], std.mem.zeroes(GraphAdjacency)); + list.capacity = grown_capacity; + } + const item: *GraphAdjacency = list.items + @as(usize, @intCast(list.count)); + item.source_node_id = zvStrdup(context.source); + item.target_node_id = zvStrdup(context.target); + item.edge_type = zvStrndup(context.edge_type, context.edge_type_len); + item.properties = zvStrndup(record.*.properties.data, record.*.properties.len); + if (item.source_node_id == null or item.target_node_id == null or item.edge_type == null or item.properties == null) { + c.free(item.source_node_id); + c.free(item.target_node_id); + c.free(item.edge_type); + c.free(item.properties); + item.* = std.mem.zeroes(GraphAdjacency); + return -1; + } + list.count += 1; + return 0; +} + +/// The Zova neighbor directions "outbound", "inbound" or "both" name. +fn neighborDirections(direction: Str, dirs: *[2]c_int) []const c_int { + const text = span(direction); + const both = std.mem.eql(u8, text, "both"); + var count: usize = 0; + if (both or std.mem.eql(u8, text, "outbound")) { + dirs[count] = c.ZOVA_GRAPH_NEIGHBOR_OUTGOING; + count += 1; + } + if (both or std.mem.eql(u8, text, "inbound")) { + dirs[count] = c.ZOVA_GRAPH_NEIGHBOR_INCOMING; + count += 1; + } + return dirs[0..count]; +} + +/// True when `graph_name` is the graph of `workspace_id`. +fn graphNameMatches(workspace_id: ?Str, graph_name: ?Str) bool { + var expected: [ID_MAX + 32]u8 = undefined; + const name = graph_name orelse return false; + return cbm_zova_workspace_graph_name(workspace_id, &expected, expected.len) == 0 and + c.strcmp(name, &expected) == 0; +} + +/// Appends the edges of one (direction, edge type) neighbor read to `list`. +fn graphAdjacencyAppend( + db: ?*Db, + graph_name: Str, + node_id: Str, + direction: c_int, + edge_type: CStr, + max_results: c_int, + list: *AdjacencyList, +) bool { + var results: c.zova_graph_keyed_neighbor_results = .{}; + defer c.zova_graph_keyed_neighbor_results_free(&results); + var payloads: c.zova_graph_edge_payload_results = .{}; + defer c.zova_graph_edge_payload_results_free(&payloads); + if (!statusOk(c.zova_graph_neighbors_keyed(&c.zova_graph_neighbors_keyed_request{ + .db = db, + .graph_name = graph_name, + .node_id = node_id, + .direction = direction, + .edge_type = edge_type, + .limit = @intCast(max_results - list.count), + .out_results = &results, + }), db, "graph.adjacency")) return false; + if (results.len == 0) return true; + + const edge_keys = interop.callocN(i64, results.len); + defer c.free(edge_keys); + if (edge_keys == null) return false; + const neighbors = results.items[0..results.len]; + for (neighbors, edge_keys[0..results.len]) |*neighbor, *edge_key| edge_key.* = neighbor.edge_key; + if (!statusOk(c.zova_graph_edge_payload_get_many(&c.zova_graph_edge_payload_get_many_request{ + .db = db, + .graph_name = graph_name, + .edge_keys = edge_keys, + .key_count = results.len, + .out_results = &payloads, + }), db, "graph.adjacency.payloads")) return false; + if (payloads.len != results.len) return false; + + const outgoing = direction == c.ZOVA_GRAPH_NEIGHBOR_OUTGOING; + for (neighbors, payloads.items[0..payloads.len]) |*neighbor, *payload| { + if (list.count >= max_results) break; + if (payload.found == 0 or payload.edge_key != neighbor.edge_key) return false; + var context: GraphAdjacencyPayloadContext = .{ + .list = list, + .max_results = max_results, + .source = if (outgoing) node_id else neighbor.node_id, + .target = if (outgoing) neighbor.node_id else node_id, + .edge_type = neighbor.edge_type, + .edge_type_len = neighbor.edge_type_len, + }; + if (c.cbm_zova_edge_payload_visit(payload.payload, payload.payload_len, graphAdjacencyAppendPayload, &context, null) != 0) return false; + } + return true; +} + +pub export fn cbm_zova_graph_session_adjacency( + session: ?*GraphSession, + workspace_id: ?Str, + graph_name: ?Str, + node_id: ?Str, + direction: ?Str, + edge_types: [*c]const CStr, + edge_type_count: c_int, + max_results: c_int, + out: ?*[*c]GraphAdjacency, + out_count: ?*c_int, +) c_int { + if (out) |items| items.* = null; + if (out_count) |count| count.* = 0; + if (!with_zova) return -1; + const graph_session = session orelse return -1; + const node = nonEmpty(node_id) orelse return -1; + const direction_name = direction orelse return -1; + if (!workspaceNameComponentValid(workspace_id) or !graphNameMatches(workspace_id, graph_name) or + edge_type_count < 0 or (edge_type_count != 0 and edge_types == null) or + max_results <= 0 or out == null or out_count == null) return -1; + var dirs_buf: [2]c_int = undefined; + const dirs = neighborDirections(direction_name, &dirs_buf); + if (dirs.len == 0) return -1; + + const loops: usize = if (edge_type_count != 0) @intCast(edge_type_count) else 1; + var list: AdjacencyList = .{ .items = interop.callocN(GraphAdjacency, 8), .count = 0, .capacity = 8 }; + if (list.items == null) return -1; + for (dirs) |dir| { + for (0..loops) |t| { + const edge_type: CStr = if (edge_type_count != 0) edge_types[t] else null; + if (!graphAdjacencyAppend(graph_session.zdb.db, graph_name.?, node, dir, edge_type, max_results, &list)) { + cbm_zova_graph_adjacency_free(list.items, list.count); + return -1; + } + } + } + out.?.* = list.items; + out_count.?.* = list.count; + return 0; +} + +pub export fn cbm_zova_graph_session_degree( + session: ?*GraphSession, + workspace_id: ?Str, + graph_name: ?Str, + node_id: ?Str, + direction: ?Str, + edge_types: [*c]const CStr, + edge_type_count: c_int, + out_degree: ?*c_int, +) c_int { + if (!with_zova) { + if (out_degree) |out| out.* = 0; + return -1; + } + const out = out_degree orelse return -1; + out.* = 0; + const graph_session = session orelse return -1; + const node = node_id orelse return -1; + const direction_name = direction orelse return -1; + if (!graphNameMatches(workspace_id, graph_name) or edge_type_count < 0 or + (edge_type_count != 0 and edge_types == null)) return -1; + var dirs_buf: [2]c_int = undefined; + const dirs = neighborDirections(direction_name, &dirs_buf); + if (dirs.len == 0) return -1; + + const db = graph_session.zdb.db; + const loops: usize = if (edge_type_count != 0) @intCast(edge_type_count) else 1; + var total: u64 = 0; + for (dirs) |dir| { + for (0..loops) |t| { + var degree: u64 = 0; + if (!statusOk(c.zova_graph_degree(&c.zova_graph_degree_request{ + .db = db, + .graph_name = graph_name.?, + .node_id = node, + .direction = dir, + .edge_type = if (edge_type_count != 0) edge_types[t] else null, + .out_degree = °ree, + }), db, "graph.degree") or degree > @as(u64, @intCast(INT_MAX)) - total) return -1; + total += degree; + } + } + out.* = @intCast(total); + return 0; +} + +pub export fn cbm_zova_graph_session_adjacency_prepare_count(_: ?*const GraphSession) c_int { + return 0; +} + +// ── Database format and open ──────────────────────────────────── + +/// Raw, read-only check of the Zova format version and the CBM schema +/// version, made before the pinned Zova SDK opens the file. +fn databaseFormatStatus(maybe_path: ?Str) FormatStatus { + const path = nonEmpty(maybe_path) orelse return c.CBM_ZOVA_DATABASE_INCOMPATIBLE; + var db: ?*c.sqlite3 = null; + var stmt: ?*c.sqlite3_stmt = null; + defer { + _ = c.sqlite3_finalize(stmt); + if (db != null) { + _ = c.sqlite3_exec(db, "ROLLBACK", null, null, null); + _ = c.sqlite3_close(db); + } + } + if (c.sqlite3_open_v2(path, &db, c.SQLITE_OPEN_READONLY | c.SQLITE_OPEN_NOMUTEX, null) != c.SQLITE_OK or + c.sqlite3_exec(db, "PRAGMA query_only=ON;BEGIN", null, null, null) != c.SQLITE_OK or + c.sqlite3_prepare_v2(db, "SELECT (SELECT value FROM _zova_meta WHERE key='format_version')," ++ + "(SELECT schema_version FROM cbm_database_schema_v1 WHERE id=1)", -1, &stmt, null) != c.SQLITE_OK or + c.sqlite3_step(stmt) != c.SQLITE_ROW or c.sqlite3_column_type(stmt, 0) != c.SQLITE_TEXT or + c.sqlite3_column_type(stmt, 1) != c.SQLITE_INTEGER) + { + return c.CBM_ZOVA_DATABASE_INCOMPATIBLE; + } + const format_text: CStr = c.sqlite3_column_text(stmt, 0); + const schema = c.sqlite3_column_int64(stmt, 1); + if (format_text == null) return c.CBM_ZOVA_DATABASE_INCOMPATIBLE; + const format = span(format_text); + const eql = std.mem.eql; + if (eql(u8, format, "12") and schema == SCHEMA_VERSION) return c.CBM_ZOVA_DATABASE_COMPATIBLE; + if ((eql(u8, format, "9") or eql(u8, format, "10") or eql(u8, format, "11")) and schema == SCHEMA_VERSION) { + return c.CBM_ZOVA_DATABASE_MIGRATION_REQUIRED; + } + if (eql(u8, format, "7") and schema == 5) return c.CBM_ZOVA_DATABASE_REPACK_REQUIRED; + return c.CBM_ZOVA_DATABASE_INCOMPATIBLE; +} + +const repack_phase_names = [_]Str{ + "source_snapshot", "temp_creation", "workspace_publish", "verification", "fsync", + "live_to_recovery_rename", "temp_to_live_rename", "final_reopen", +}; + +/// Registers the SQL functions and turns foreign keys on for a fresh handle. +fn prepareHandle(db: ?*Db) bool { + return registerSqlFunctions(db) and execSql(db, "PRAGMA foreign_keys=ON") == c.ZOVA_OK; +} + +fn openZova(maybe_path: ?Str, read_only: bool, out: *ZovaDb) bool { + out.* = .{}; + const path = maybe_path orelse return false; + const format_status = databaseFormatStatus(path); + if (format_status == c.CBM_ZOVA_DATABASE_REPACK_REQUIRED) { + interop.log(c.CBM_LOG_ERROR, "zova.open", .{ "path", path, "status", "repack_required" }); + return false; + } + if (format_status == c.CBM_ZOVA_DATABASE_MIGRATION_REQUIRED) { + interop.log(c.CBM_LOG_ERROR, "zova.open", .{ "path", path, "status", "format_migration_required" }); + return false; + } + var err: c.zova_message = .{}; + defer c.zova_message_free(&err); + const status = if (read_only) + c.zova_database_open_with_options(&c.zova_database_open_options_request{ + .path = path, + .flags = c.ZOVA_OPEN_READ_ONLY, + .busy_timeout_ms = 5000, + .out_db = &out.db, + .out_error_message = &err, + }) + else + c.zova_database_open(&c.zova_database_open_request{ + .path = path, + .out_db = &out.db, + .out_error_message = &err, + }); + if (status != c.ZOVA_OK) { + interop.log(c.CBM_LOG_ERROR, "zova.open", .{ + "path", path, + "status", c.zova_status_name(status), + "msg", if (err.data != null) @as(CStr, err.data) else @as(CStr, ""), + }); + return false; + } + user_publish_test_metrics.database_handle_open_count += 1; + if (!prepareHandle(out.db)) { + closeZova(out); + return false; + } + return true; +} + +/// Runs one capability probe without logging expected feature failures and +/// says whether it produced a row; NULL when the statement failed. A +/// successful statement with no rows is still a successful capability (for +/// example BM25 on an empty FTS table). +fn zovaProbeSql(db: ?*Db, sql: Str) ?bool { + if (db == null) return null; + var stmt: ?*Stmt = null; + if (c.zova_database_prepare(&c.zova_database_prepare_request{ .db = db, .sql = sql, .out_statement = &stmt }) != c.ZOVA_OK or stmt == null) { + return null; + } + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + const stepped = stepStatement(stmt, &step) == c.ZOVA_OK; + if (c.zova_statement_finalize(stmt) != c.ZOVA_OK or !stepped) return null; + return step == c.ZOVA_STEP_ROW; +} + +/// True when the probe ran and produced a row. +fn zovaProbeRow(db: ?*Db, sql: Str) bool { + return zovaProbeSql(db, sql) orelse false; +} + +pub export fn cbm_zova_probe_sql_capabilities(zova_path: ?Str, out_capabilities: ?*SqlCapabilities) c_int { + if (!with_zova) { + if (out_capabilities) |out| out.* = std.mem.zeroes(SqlCapabilities); + return -1; + } + const path = nonEmpty(zova_path) orelse return -1; + const out = out_capabilities orelse return -1; + out.* = std.mem.zeroes(SqlCapabilities); + var zdb: ZovaDb = .{}; + if (!openZova(path, false, &zdb)) return -1; + defer closeZova(&zdb); + const db = zdb.db; + + out.canonical_workspace_metadata = zovaProbeRow(db, "SELECT 1 FROM sqlite_master WHERE type='table' AND " ++ + "name='cbm_nodes_v1' LIMIT 1"); + + // Exercise the complete FTS surface on a disposable table instead of + // relying on a converted sidecar containing a particular application + // table. This covers creation, insert, MATCH, update and delete. + const fts_setup = + "DROP TABLE IF EXISTS cbm_zova_capability_fts_v1;" ++ + "CREATE VIRTUAL TABLE cbm_zova_capability_fts_v1 USING fts5(" ++ + "workspace_id UNINDEXED,node_id UNINDEXED,name,qualified_name,file_path,label," ++ + "tokenize='unicode61 remove_diacritics 2');" ++ + "INSERT INTO cbm_zova_capability_fts_v1" ++ + "(workspace_id,node_id,name,qualified_name,file_path,label) " ++ + "VALUES('w1','n1','AlphaBeta','pkg::AlphaBeta','src/a.c','Function');"; + const fts_status = execSql(db, fts_setup); + var fts_surface = fts_status == c.ZOVA_OK; + if (!fts_surface) { + interop.log(c.CBM_LOG_ERROR, "zova.capability", .{ + "feature", "fts5", + "phase", "create_insert", + "status", c.zova_status_name(fts_status), + "msg", c.zova_database_last_error_message(db), + }); + } + if (fts_surface) { + fts_surface = zovaProbeRow(db, "SELECT rowid FROM cbm_zova_capability_fts_v1 " ++ + "WHERE cbm_zova_capability_fts_v1 MATCH 'AlphaBeta' LIMIT 1"); + } + if (fts_surface) { + const fts_update = + "UPDATE cbm_zova_capability_fts_v1 SET name='BetaGamma' WHERE rowid=1;" ++ + "DELETE FROM cbm_zova_capability_fts_v1 WHERE rowid=1;"; + const update_status = execSql(db, fts_update); + fts_surface = update_status == c.ZOVA_OK and + zovaProbeRow(db, "SELECT count(*) FROM cbm_zova_capability_fts_v1"); + if (!fts_surface and update_status != c.ZOVA_OK) { + interop.log(c.CBM_LOG_ERROR, "zova.capability", .{ + "feature", "fts5", + "phase", "update_delete", + "status", c.zova_status_name(update_status), + "msg", c.zova_database_last_error_message(db), + }); + } + } + out.fts5 = fts_surface; + if (fts_surface) { + fts_surface = execSql(db, "INSERT INTO cbm_zova_capability_fts_v1" ++ + "(workspace_id,node_id,name,qualified_name,file_path,label) " ++ + "VALUES('w1','n2','Alpha','pkg::Alpha','src/b.c','Function');") == c.ZOVA_OK; + } + out.bm25 = fts_surface and zovaProbeRow(db, "SELECT bm25(cbm_zova_capability_fts_v1) " ++ + "FROM cbm_zova_capability_fts_v1 " ++ + "WHERE cbm_zova_capability_fts_v1 MATCH 'Alpha' LIMIT 1"); + _ = execSql(db, "DROP TABLE IF EXISTS cbm_zova_capability_fts_v1;"); + + out.json = zovaProbeRow(db, "SELECT json_extract('{\"workspace_id\":\"w1\",\"value\":1}', " ++ + "'$.value') WHERE json_extract('{\"workspace_id\":\"w1\",\"value\":1}', " ++ + "'$.workspace_id') = 'w1'"); + out.recursive_cte = zovaProbeRow(db, "WITH RECURSIVE n(workspace_id,x) AS (VALUES('w1',1) UNION ALL " ++ + "SELECT workspace_id,x+1 FROM n WHERE workspace_id='w1' AND x<2) " ++ + "SELECT count(*) FROM n WHERE workspace_id='w1'"); + + const function_probes = [_]Str{ + "SELECT cbm_camel_split('AlphaBeta')", + "SELECT regexp('alpha', 'Alpha')", + "SELECT iregexp('alpha', 'Alpha')", + "SELECT cbm_cosine_i8(x'01', x'01')", + }; + out.cbm_scalar_functions = true; + for (function_probes) |probe| { + if (!zovaProbeRow(db, probe)) { + out.cbm_scalar_functions = false; + break; + } + } + + out.transactions = execSql(db, "BEGIN; CREATE TEMP TABLE cbm_zova_capability_probe(x INTEGER); " ++ + "INSERT INTO cbm_zova_capability_probe VALUES(1); ROLLBACK;") == c.ZOVA_OK; + return 0; +} + +/// Enables WAL on a database Zova just created and verifies its 64 KiB pages. +fn configureNewZovaDatabase(db: ?*Db) bool { + if (!statusOk(execSql(db, "PRAGMA journal_mode=WAL"), db, "zova.journal_mode_configure")) return false; + const stmt = prepareZova(db, "PRAGMA page_size", "zova.page_size_prepare") orelse return false; + const page_size = stepRowI64Logged(db, stmt, "zova.page_size_step", "zova.page_size_column"); + if (c.zova_statement_finalize(stmt) != c.ZOVA_OK) return false; + return (page_size orelse return false) == 65536; +} + +fn openOrCreateZova(path: Str, out: *ZovaDb) bool { + if (c.cbm_file_exists(path)) return openZova(path, false, out); + out.* = .{}; + var err: c.zova_message = .{}; + defer c.zova_message_free(&err); + const status = c.zova_database_create_with_options(&c.zova_database_create_options_request{ + .path = path, + .page_size = 65536, + .out_db = &out.db, + .out_error_message = &err, + }); + if (status != c.ZOVA_OK) { + interop.log(c.CBM_LOG_ERROR, "zova.registry_create", .{ + "path", path, + "status", c.zova_status_name(status), + "msg", if (err.data != null) @as(CStr, err.data) else @as(CStr, ""), + }); + return false; + } + user_publish_test_metrics.database_handle_open_count += 1; + // Zova selected 64 KiB pages before creating its private catalog. Keep the + // new handle, enable WAL, and verify the effective page size in place. + if (!configureNewZovaDatabase(out.db) or !prepareHandle(out.db)) { + closeZova(out); + _ = c.cbm_unlink(path); + return false; + } + return true; +} + +// ── Workspace registry ────────────────────────────────────────── + +fn workspaceRegistryInit(db: ?*Db) bool { + const sql = + "CREATE TABLE IF NOT EXISTS cbm_workspace_registry(" ++ + "workspace_key INTEGER PRIMARY KEY, workspace_id TEXT NOT NULL UNIQUE, " ++ + "canonical_root TEXT NOT NULL UNIQUE, " ++ + "id_format_version INTEGER NOT NULL, active_generation INTEGER NOT NULL DEFAULT 0);"; + return statusOk(execSql(db, sql), db, "workspace.registry_init"); +} + +fn workspaceLegacyGenerationInit(db: ?*Db) bool { + // The normal single-file schema uses cbm_database_generation_v1. Keep this + // table out of fresh databases unless an old sidecar coordination API is + // explicitly invoked. + const sql = + "CREATE TABLE IF NOT EXISTS cbm_workspace_generations(" ++ + "workspace_key INTEGER NOT NULL, generation INTEGER NOT NULL, state TEXT NOT NULL " ++ + "CHECK(state IN ('building','ready','failed','retired')), " ++ + "PRIMARY KEY(workspace_key,generation), " ++ + "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " ++ + "ON DELETE CASCADE) WITHOUT ROWID;"; + return statusOk(execSql(db, sql), db, "workspace.legacy_generation_init"); +} + +fn workspaceRegistryBegin(db: ?*Db) bool { + return statusOk(c.zova_database_begin_immediate(&c.zova_database_simple_request{ .db = db }), db, "workspace.begin"); +} + +fn workspaceRegistryCommit(db: ?*Db) bool { + return statusOk(c.zova_database_commit(&c.zova_database_simple_request{ .db = db }), db, "workspace.commit"); +} + +fn workspaceRegistryRollback(db: ?*Db) void { + _ = c.zova_database_rollback(&c.zova_database_simple_request{ .db = db }); +} + +// ── User database schema ──────────────────────────────────────── + +/// The first int64 of the first row of `sql`; NULL when it has none. +fn userSchemaScalar(db: ?*Db, sql: Str) ?i64 { + if (db == null) return null; + const stmt = prepareZova(db, sql, "user_database.schema_probe") orelse return null; + defer finalize(stmt); + return stepRowI64(stmt); +} + +fn userSchemaScalarIs(db: ?*Db, sql: Str, expected: i64) bool { + return (userSchemaScalar(db, sql) orelse return false) == expected; +} + +fn userSchemaV3IsWellFormed(db: ?*Db) bool { + const sql = + "SELECT count(*) FROM sqlite_master WHERE type IN ('table','view') AND name IN (" ++ + "'cbm_workspace_registry','cbm_database_generation_v1'," ++ + "'cbm_projects_v1','cbm_files_v1','cbm_nodes_v1','cbm_file_hashes_v1'," ++ + "'cbm_token_vector_metadata_v1','cbm_nodes_fts_v1'," ++ + "'cbm_workspace_index_state_v1','cbm_project_summaries_v2'," ++ + "'cbm_generation_integrity_v2')"; + return userSchemaScalarIs(db, sql, 11); +} + +fn userSchemaV4IsWellFormed(db: ?*Db) bool { + return userSchemaV3IsWellFormed(db) and + userSchemaScalarIs(db, "SELECT count(*) FROM pragma_table_info('cbm_workspace_migrations_v1')", 17); +} + +fn userDatabaseSchemaIsCurrent(db: ?*Db) bool { + return userSchemaV4IsWellFormed(db) and + // columns + userSchemaScalarIs(db, "SELECT count(*) FROM pragma_table_info('cbm_workspace_health_v1')", 5) and + // exact_columns + userSchemaScalarIs(db, "SELECT count(*) FROM pragma_table_info('cbm_workspace_health_v1') " ++ + "WHERE (cid=0 AND name='workspace_key' AND type='INTEGER' AND pk=1 AND " ++ + "dflt_value IS NULL) OR " ++ + "(cid=1 AND name='state' AND type='TEXT' AND \"notnull\"=1 AND pk=0 AND " ++ + "dflt_value IS NULL) OR " ++ + "(cid=2 AND name='reason' AND type='TEXT' AND \"notnull\"=1 AND pk=0 AND " ++ + "dflt_value IS NULL) OR " ++ + "(cid=3 AND name='checked_generation' AND type='INTEGER' AND \"notnull\"=1 " ++ + "AND pk=0 AND dflt_value IS NULL) OR " ++ + "(cid=4 AND name='checked_at' AND type='TEXT' AND \"notnull\"=1 AND pk=0 AND " ++ + "dflt_value IS NULL)", 5) and + // foreign_keys + userSchemaScalarIs(db, "SELECT count(*) FROM pragma_foreign_key_list(" ++ + "'cbm_workspace_health_v1') WHERE \"table\"='cbm_workspace_registry' " ++ + "AND \"from\"='workspace_key' AND \"to\"='workspace_key'", 1) and + // state_constraint + userSchemaScalarIs(db, "SELECT count(*) FROM sqlite_master WHERE type='table' AND " ++ + "name='cbm_workspace_health_v1' AND " ++ + "instr(sql,\"state IN ('healthy','rebuild_required')\") > 0", 1) and + // token_columns + userSchemaScalarIs(db, "SELECT count(*) FROM pragma_table_info(" ++ + "'cbm_token_vector_metadata_v1') WHERE " ++ + "(cid=0 AND name='token_key' AND type='INTEGER' AND pk=1) OR " ++ + "(cid=1 AND name='workspace_key' AND type='INTEGER' AND \"notnull\"=1) OR " ++ + "(cid=2 AND name='token' AND type='TEXT' AND \"notnull\"=1) OR " ++ + "(cid=3 AND name='idf' AND type='REAL' AND \"notnull\"=1)", 4) and + // topology_columns + userSchemaScalarIs(db, "SELECT count(*) FROM (" ++ + "SELECT 1 FROM pragma_table_info('cbm_nodes_v1') WHERE " ++ + "(cid=0 AND name='zova_node_key' AND type='INTEGER' AND pk=1) OR " ++ + "(cid=1 AND name='workspace_key' AND type='INTEGER' AND \"notnull\"=1) OR " ++ + "(cid=2 AND name='label' AND type='TEXT' AND \"notnull\"=1) OR " ++ + "(cid=3 AND name='name' AND type='TEXT' AND \"notnull\"=1) OR " ++ + "(cid=4 AND name='qualified_name' AND type='TEXT' AND \"notnull\"=1) OR " ++ + "(cid=5 AND name='file_key' AND type='INTEGER' AND \"notnull\"=1) OR " ++ + "(cid=6 AND name='start_line' AND type='INTEGER' AND \"notnull\"=1) OR " ++ + "(cid=7 AND name='end_line' AND type='INTEGER' AND \"notnull\"=1) OR " ++ + "(cid=8 AND name='properties' AND type='TEXT' AND \"notnull\"=1) OR " ++ + "(cid=9 AND name='source_ordinal' AND type='INTEGER' AND \"notnull\"=1))", 10) and + // compact_foreign_keys + userSchemaScalarIs(db, "SELECT count(*) FROM (" ++ + "SELECT 1 FROM pragma_foreign_key_list('cbm_nodes_v1') " ++ + "WHERE \"table\"='cbm_files_v1' AND ((seq=0 AND \"from\"='workspace_key' " ++ + "AND \"to\"='workspace_key') OR (seq=1 AND \"from\"='file_key' " ++ + "AND \"to\"='file_key')))", 2) and + // compact_child_indexes + userSchemaScalarIs(db, "SELECT count(*) FROM sqlite_master WHERE type='index' AND " ++ + "name='cbm_nodes_v1_workspace_file'", 1) and + // guard_triggers + userSchemaScalarIs(db, "SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name IN (" ++ + "'cbm_nodes_v1_file_workspace_bi','cbm_nodes_v1_file_workspace_bu'," ++ + "'cbm_edges_v1_endpoint_workspace_bi'," ++ + "'cbm_edges_v1_endpoint_workspace_bu')", 0) and + // forbidden_objects + userSchemaScalarIs(db, "SELECT count(*) FROM sqlite_master WHERE " ++ + "name IN ('cbm_edges_v1','cbm_zova_trace_nodes_v1','cbm_zova_edge_metadata_v1'," ++ + "'cbm_fts_rowmap_v1','cbm_node_vectors_compat_v1'," ++ + "'cbm_token_vectors_compat_v1','_zova_vector_norms') " ++ + "OR name GLOB 'cbm_fts_w1_*'", 0); +} + +const user_database_schema_sql = + "CREATE TABLE IF NOT EXISTS cbm_database_schema_v1(" ++ + "id INTEGER PRIMARY KEY CHECK(id = 1)," ++ + "schema_version INTEGER NOT NULL," ++ + "metadata_projection_version INTEGER NOT NULL," ++ + "edge_metadata_projection_version INTEGER NOT NULL," ++ + "created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);" ++ + "INSERT OR IGNORE INTO cbm_database_schema_v1" ++ + "(id,schema_version,metadata_projection_version,edge_metadata_projection_version)" ++ + "VALUES(1,7,0,0);" ++ + "CREATE TABLE IF NOT EXISTS cbm_database_generation_v1(" ++ + "workspace_key INTEGER NOT NULL, generation INTEGER NOT NULL," ++ + "state TEXT NOT NULL CHECK(state IN ('building','ready','failed','retired'))," ++ + "PRIMARY KEY(workspace_key,generation)," ++ + "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " ++ + "ON DELETE CASCADE) WITHOUT ROWID;" ++ + "CREATE TABLE IF NOT EXISTS cbm_projects_v1(" ++ + "workspace_key INTEGER PRIMARY KEY, project TEXT NOT NULL, root_path TEXT NOT NULL," ++ + "indexed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP," ++ + "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " ++ + "ON DELETE CASCADE);" ++ + "CREATE TABLE IF NOT EXISTS cbm_files_v1(" ++ + "file_key INTEGER PRIMARY KEY, workspace_key INTEGER NOT NULL, file_path TEXT NOT NULL," ++ + "UNIQUE(workspace_key,file_path),UNIQUE(workspace_key,file_key)," ++ + "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " ++ + "ON DELETE CASCADE);" ++ + "CREATE TABLE IF NOT EXISTS cbm_nodes_v1(" ++ + "zova_node_key INTEGER PRIMARY KEY, workspace_key INTEGER NOT NULL," ++ + "label TEXT NOT NULL, name TEXT NOT NULL, qualified_name TEXT NOT NULL," ++ + "file_key INTEGER NOT NULL, start_line INTEGER NOT NULL, end_line INTEGER NOT NULL," ++ + "properties TEXT NOT NULL DEFAULT '{}',source_ordinal INTEGER NOT NULL DEFAULT 0 " ++ + "CHECK(source_ordinal>=0)," ++ + "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " ++ + "ON DELETE CASCADE," ++ + "FOREIGN KEY(workspace_key,file_key) " ++ + "REFERENCES cbm_files_v1(workspace_key,file_key));" ++ + "CREATE INDEX IF NOT EXISTS cbm_nodes_v1_workspace_file " ++ + "ON cbm_nodes_v1(workspace_key,file_key);" ++ + "CREATE TABLE IF NOT EXISTS cbm_file_hashes_v1(" ++ + "file_key INTEGER PRIMARY KEY, content_hash TEXT NOT NULL," ++ + "mtime_ns INTEGER NOT NULL DEFAULT 0, size_bytes INTEGER NOT NULL DEFAULT 0," ++ + "FOREIGN KEY(file_key) REFERENCES cbm_files_v1(file_key) ON DELETE CASCADE);" ++ + "CREATE TABLE IF NOT EXISTS cbm_token_vector_metadata_v1(" ++ + "token_key INTEGER PRIMARY KEY, workspace_key INTEGER NOT NULL," ++ + "token TEXT NOT NULL, idf REAL NOT NULL, UNIQUE(workspace_key,token)," ++ + "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " ++ + "ON DELETE CASCADE);" ++ + "CREATE VIEW IF NOT EXISTS cbm_nodes_fts_content_v1 AS " ++ + "SELECT n.zova_node_key,cbm_camel_split(n.name) AS name,n.qualified_name,f.file_path,n.label " ++ + "FROM cbm_nodes_v1 n JOIN cbm_files_v1 f ON f.file_key=n.file_key;" ++ + "CREATE VIRTUAL TABLE IF NOT EXISTS cbm_nodes_fts_v1 USING fts5(" ++ + "name,qualified_name,file_path,label,content='cbm_nodes_fts_content_v1'," ++ + "content_rowid='zova_node_key');" ++ + node_fts_insert_trigger_sql ++ + "CREATE TRIGGER IF NOT EXISTS cbm_nodes_v1_fts_bd BEFORE DELETE ON cbm_nodes_v1 BEGIN " ++ + "INSERT INTO cbm_nodes_fts_v1(cbm_nodes_fts_v1,rowid,name,qualified_name,file_path,label) " ++ + "SELECT 'delete',OLD.zova_node_key,cbm_camel_split(OLD.name),OLD.qualified_name,f.file_path,OLD.label " ++ + "FROM cbm_files_v1 f WHERE f.file_key=OLD.file_key; END;" ++ + "CREATE TRIGGER IF NOT EXISTS cbm_nodes_v1_fts_bu " ++ + "BEFORE UPDATE OF name,qualified_name,file_key,label ON cbm_nodes_v1 BEGIN " ++ + "INSERT INTO cbm_nodes_fts_v1(cbm_nodes_fts_v1,rowid,name,qualified_name,file_path,label) " ++ + "SELECT 'delete',OLD.zova_node_key,cbm_camel_split(OLD.name),OLD.qualified_name,f.file_path,OLD.label " ++ + "FROM cbm_files_v1 f WHERE f.file_key=OLD.file_key; END;" ++ + "CREATE TRIGGER IF NOT EXISTS cbm_nodes_v1_fts_au " ++ + "AFTER UPDATE OF name,qualified_name,file_key,label ON cbm_nodes_v1 BEGIN " ++ + "INSERT INTO cbm_nodes_fts_v1(rowid,name,qualified_name,file_path,label) " ++ + "SELECT NEW.zova_node_key,cbm_camel_split(NEW.name),NEW.qualified_name,f.file_path,NEW.label " ++ + "FROM cbm_files_v1 f WHERE f.file_key=NEW.file_key; END;" ++ + "CREATE TABLE IF NOT EXISTS cbm_workspace_index_state_v1(" ++ + "workspace_key INTEGER PRIMARY KEY, generation INTEGER NOT NULL," ++ + "model_fingerprint TEXT NOT NULL, vector_dimensions INTEGER NOT NULL," ++ + "indexed_at TEXT NOT NULL," ++ + "FOREIGN KEY(workspace_key,generation) REFERENCES cbm_database_generation_v1(workspace_key,generation) " ++ + "ON DELETE CASCADE);" ++ + "CREATE TABLE IF NOT EXISTS cbm_project_summaries_v2(" ++ + "workspace_key INTEGER PRIMARY KEY, summary TEXT NOT NULL, source_hash TEXT NOT NULL," ++ + "created_at TEXT NOT NULL, updated_at TEXT NOT NULL," ++ + "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " ++ + "ON DELETE CASCADE);" ++ + "CREATE TABLE IF NOT EXISTS cbm_generation_integrity_v2(" ++ + "workspace_key INTEGER NOT NULL, generation INTEGER NOT NULL," ++ + "graph_nodes INTEGER NOT NULL, graph_edges INTEGER NOT NULL," ++ + "metadata_nodes INTEGER NOT NULL, metadata_edges INTEGER NOT NULL," ++ + "metadata_topology_edges INTEGER NOT NULL, fts_rows INTEGER NOT NULL," ++ + "node_vector_rows INTEGER NOT NULL, token_vector_rows INTEGER NOT NULL," ++ + "node_vectors INTEGER NOT NULL, token_vectors INTEGER NOT NULL," ++ + "metadata_sha256 TEXT NOT NULL, fts_sha256 TEXT NOT NULL," ++ + "topology_sha256 TEXT NOT NULL, node_vector_sha256 TEXT NOT NULL," ++ + "token_vector_sha256 TEXT NOT NULL," ++ + "PRIMARY KEY(workspace_key,generation)," ++ + "FOREIGN KEY(workspace_key,generation) REFERENCES cbm_database_generation_v1(workspace_key,generation) " ++ + "ON DELETE CASCADE) WITHOUT ROWID;" ++ + "CREATE TABLE IF NOT EXISTS cbm_workspace_migrations_v1(" ++ + "workspace_key INTEGER PRIMARY KEY," ++ + "migration_version INTEGER NOT NULL CHECK(migration_version=1)," ++ + "project TEXT NOT NULL,root_path TEXT NOT NULL," ++ + "source_db_path TEXT NOT NULL,source_zova_path TEXT NOT NULL," ++ + "source_generation INTEGER NOT NULL,target_generation INTEGER NOT NULL DEFAULT 0," ++ + "state TEXT NOT NULL CHECK(state IN ('prepared','copying','active','failed'," ++ + "'rolled_back','cleanup_pending','retired'))," ++ + "metadata_sha256 TEXT NOT NULL,fts_sha256 TEXT NOT NULL," ++ + "topology_sha256 TEXT NOT NULL,node_vector_sha256 TEXT NOT NULL," ++ + "token_vector_sha256 TEXT NOT NULL,prepared_at TEXT NOT NULL," ++ + "activated_at TEXT,retired_at TEXT," ++ + "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " ++ + "ON DELETE CASCADE);" ++ + "CREATE TABLE IF NOT EXISTS cbm_workspace_health_v1(" ++ + "workspace_key INTEGER PRIMARY KEY," ++ + "state TEXT NOT NULL CHECK(state IN ('healthy','rebuild_required'))," ++ + "reason TEXT NOT NULL,checked_generation INTEGER NOT NULL,checked_at TEXT NOT NULL," ++ + "FOREIGN KEY(workspace_key) REFERENCES cbm_workspace_registry(workspace_key) " ++ + "ON DELETE CASCADE);"; + +fn userDatabaseEnsureSchema(db: ?*Db) bool { + if (db == null) return false; + const schema_table_count = userSchemaScalar(db, "SELECT count(*) FROM sqlite_master WHERE type='table' " ++ + "AND name='cbm_database_schema_v1'") orelse return false; + if (schema_table_count != 0 and schema_table_count != 1) return false; + var schema_version: i64 = 0; + if (schema_table_count == 1) { + schema_version = userSchemaScalar(db, "SELECT schema_version FROM cbm_database_schema_v1 WHERE id=1") orelse return false; + if (schema_version < 1 or schema_version > SCHEMA_VERSION) return false; + } + if (schema_version == SCHEMA_VERSION) return userDatabaseSchemaIsCurrent(db); + // Every pre-v7 file is immutable repack input. Never run partial in-place + // DDL against a live v5/Zova-v7 database. + if (schema_table_count == 1) return false; + if (!workspaceRegistryBegin(db) or !workspaceRegistryInit(db)) { + workspaceRegistryRollback(db); + return false; + } + + var ok = statusOk(execSql(db, user_database_schema_sql), db, "user_database.schema_init"); + if (ok and testFailPhase("schema_v6_before_commit")) ok = false; + ok = ok and workspaceRegistryCommit(db); + if (!ok) workspaceRegistryRollback(db); + return ok; +} + +/// Bootstrap the SQL portion of the user-local cbm.zova database. Publication +/// callers reuse userDatabaseEnsureSchema() on their existing handle. +pub export fn cbm_zova_user_database_init(zova_path: ?Str) c_int { + if (!with_zova) return -1; + const path = nonEmpty(zova_path) orelse return -1; + var zdb: ZovaDb = .{}; + if (!openOrCreateZova(path, &zdb)) return -1; + user_publish_test_metrics.database_open_count += 1; + const ok = userDatabaseEnsureSchema(zdb.db); + closeZova(&zdb); + user_publish_test_metrics.database_close_count += 1; + return if (ok) 0 else -1; +} + +fn verifyWorkspaceSchema(db: ?*Db) bool { + if (db == null) return false; + const Check = struct { sql: Str, expected: i64 }; + const checks = [_]Check{ + .{ .sql = "PRAGMA foreign_keys", .expected = 1 }, + .{ .sql = "SELECT schema_version FROM cbm_database_schema_v1 WHERE id=1", .expected = SCHEMA_VERSION }, + .{ .sql = "SELECT count(*) FROM pragma_foreign_key_list('cbm_nodes_v1') " ++ + "WHERE \"table\"='cbm_files_v1'", .expected = 2 }, + }; + for (checks) |check| { + const stmt = prepareZova(db, check.sql, "workspace_schema.verify_prepare") orelse return false; + defer finalize(stmt); + if ((stepRowI64(stmt) orelse return false) != check.expected) return false; + } + const violation = zovaProbeSql(db, "PRAGMA foreign_key_check") orelse return false; + return !violation; +} + +pub export fn cbm_zova_user_database_generation_begin( + zova_path: ?Str, + root_path: ?Str, + generation: i64, + out_workspace_id: ?[*]u8, + out_workspace_id_size: usize, +) c_int { + if (!with_zova) return -1; + const path = nonEmpty(zova_path) orelse return -1; + const out = out_workspace_id orelse return -1; + if (nonEmpty(root_path) == null or generation <= 0 or out_workspace_id_size < ID_MAX) return -1; + out[0] = 0; + if (cbm_zova_user_database_init(path) != 0 or + cbm_zova_workspace_get_or_create_at(path, root_path, out, out_workspace_id_size) != 0) return -1; + + var zdb: ZovaDb = .{}; + defer closeZova(&zdb); + if (!openOrCreateZova(path, &zdb) or !workspaceRegistryBegin(zdb.db)) return -1; + const db = zdb.db; + const stmt = prepareZova(db, "INSERT INTO cbm_database_generation_v1(workspace_key,generation,state) " ++ + "SELECT workspace_key,?2,'building' FROM cbm_workspace_registry WHERE workspace_id=?1 " ++ + "ON CONFLICT(workspace_key,generation) DO UPDATE SET state='building'", "user_generation.begin_prepare"); + var ok = stmt != null and + workspaceBindText(db, stmt, 1, out, "user_generation.begin_bind_workspace") and + workspaceBindI64(db, stmt, 2, generation, "user_generation.begin_bind_generation") and + workspaceStepDone(db, stmt, "user_generation.begin_step"); + finalize(stmt); + ok = ok and workspaceRegistryCommit(db); + if (!ok) { + workspaceRegistryRollback(db); + out[0] = 0; + } + return if (ok) 0 else -1; +} + +/// Whether the workspace's row for `generation` is in state "building". +fn userGenerationIsBuilding(db: ?*Db, workspace_id: Str, generation: i64) bool { + const stmt = prepareZova(db, "SELECT state FROM cbm_database_generation_v1 " ++ + "WHERE workspace_key=(SELECT workspace_key FROM cbm_workspace_registry " ++ + "WHERE workspace_id=?1) AND generation=?2", "user_generation.finish_lookup_prepare") orelse return false; + defer finalize(stmt); + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + var state: [16]u8 = @splat(0); + return workspaceBindText(db, stmt, 1, workspace_id, "user_generation.finish_lookup_bind_workspace") and + workspaceBindI64(db, stmt, 2, generation, "user_generation.finish_lookup_bind_generation") and + statusOk(stepStatement(stmt, &step), db, "user_generation.finish_lookup_step") and + step == c.ZOVA_STEP_ROW and + columnTextInto(db, stmt, 0, &state, "user_generation.finish_lookup_column") and + c.strcmp(&state, "building") == 0; +} + +pub export fn cbm_zova_user_database_generation_finish(zova_path: ?Str, workspace_id: ?Str, generation: i64, ready: bool) c_int { + if (!with_zova) return -1; + const path = nonEmpty(zova_path) orelse return -1; + if (!workspaceNameComponentValid(workspace_id) or generation <= 0) return -1; + const id = workspace_id.?; + var zdb: ZovaDb = .{}; + defer closeZova(&zdb); + if (!openOrCreateZova(path, &zdb) or !workspaceRegistryBegin(zdb.db)) return -1; + const db = zdb.db; + + var ok = userGenerationIsBuilding(db, id, generation); + + const next_state: Str = if (ready) "ready" else "failed"; + if (ok) { + const stmt = prepareZova(db, "UPDATE cbm_database_generation_v1 SET state=?1 " ++ + "WHERE workspace_key=(SELECT workspace_key FROM cbm_workspace_registry " ++ + "WHERE workspace_id=?2) AND generation=?3", "user_generation.finish_update_prepare"); + ok = stmt != null and + workspaceBindText(db, stmt, 1, next_state, "user_generation.finish_update_bind_state") and + workspaceBindText(db, stmt, 2, id, "user_generation.finish_update_bind_workspace") and + workspaceBindI64(db, stmt, 3, generation, "user_generation.finish_update_bind_generation") and + workspaceStepDone(db, stmt, "user_generation.finish_update_step"); + finalize(stmt); + } + if (ok and ready) { + const stmt = prepareZova(db, "UPDATE cbm_workspace_registry SET active_generation=?1 " ++ + "WHERE workspace_id=?2", "user_generation.finish_active_prepare"); + ok = stmt != null and + workspaceBindI64(db, stmt, 1, generation, "user_generation.finish_active_bind_generation") and + workspaceBindText(db, stmt, 2, id, "user_generation.finish_active_bind_workspace") and + workspaceStepDone(db, stmt, "user_generation.finish_active_step"); + finalize(stmt); + } + ok = ok and workspaceRegistryCommit(db); + if (!ok) workspaceRegistryRollback(db); + return if (ok) 0 else -1; +} + +/// The registry key of `workspace_id`; NULL when it has none. +fn workspaceKeyForId(db: ?*Db, workspace_id: ?Str) ?i64 { + const id = nonEmpty(workspace_id) orelse return null; + if (db == null) return null; + const stmt = prepareZova(db, "SELECT workspace_key FROM cbm_workspace_registry WHERE workspace_id=?1", "workspace.key_prepare") orelse return null; + defer finalize(stmt); + if (!workspaceBindText(db, stmt, 1, id, "workspace.key_bind")) return null; + const key = stepRowI64Logged(db, stmt, "workspace.key_step", "workspace.key_column") orelse return null; + return if (key > 0) key else null; +} + +/// The positive key `sql` selects; NULL when it yields none. +fn nextPrivateKey(db: ?*Db, sql: Str, phase: Str) ?i64 { + if (db == null) return null; + const stmt = prepareZova(db, sql, phase) orelse return null; + defer finalize(stmt); + const key = stepRowI64Logged(db, stmt, phase, phase) orelse return null; + return if (key > 0) key else null; +} + +fn fileKeyGetOrCreate(db: ?*Db, workspace_key: i64, file_path: CStr) ?i64 { + if (db == null or workspace_key <= 0 or file_path == null) return null; + { + const stmt = prepareZova(db, "INSERT INTO cbm_files_v1(workspace_key,file_path) VALUES(?1,?2) " ++ + "ON CONFLICT(workspace_key,file_path) DO NOTHING", "files.insert_prepare") orelse return null; + defer finalize(stmt); + if (!workspaceBindI64(db, stmt, 1, workspace_key, "files.insert_workspace") or + !workspaceBindText(db, stmt, 2, file_path, "files.insert_path") or + !workspaceStepDone(db, stmt, "files.insert_step")) return null; + } + const stmt = prepareZova(db, "SELECT file_key FROM cbm_files_v1 WHERE workspace_key=?1 AND file_path=?2", "files.lookup_prepare") orelse return null; + defer finalize(stmt); + if (!workspaceBindI64(db, stmt, 1, workspace_key, "files.lookup_workspace") or + !workspaceBindText(db, stmt, 2, file_path, "files.lookup_path")) return null; + const key = stepRowI64Logged(db, stmt, "files.lookup_step", "files.lookup_column") orelse return null; + return if (key > 0) key else null; +} + +/// Reads the legacy generation state of (workspace, generation) into +/// `out_state`. +fn workspaceQueryState(db: ?*Db, workspace_id: Str, generation: i64, out_state: []u8) bool { + const stmt = prepareZova(db, "SELECT state FROM cbm_workspace_generations " ++ + "WHERE workspace_key=(SELECT workspace_key FROM cbm_workspace_registry " ++ + "WHERE workspace_id=?1) AND generation=?2", "workspace.state_prepare") orelse return false; + defer finalize(stmt); + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + return workspaceBindText(db, stmt, 1, workspace_id, "workspace.state_bind_id") and + workspaceBindI64(db, stmt, 2, generation, "workspace.state_bind_generation") and + statusOk(stepStatement(stmt, &step), db, "workspace.state_step") and + step == c.ZOVA_STEP_ROW and + columnTextInto(db, stmt, 0, out_state, "workspace.state_column"); +} + +// ── Node key index ────────────────────────────────────────────── + +// Stable node id -> Zova node key for one delta publish. Stable ids are hashes, +// so the key can only be found from the graph; one paged scan here replaces a +// full scan per looked-up node. Built before the delta's graph writes: nodes the +// delta re-puts are resolved from the put results first, and nodes it leaves +// alone keep their keys. +const NodeKeyEntry = extern struct { + node_id: [*c]u8, + node_key: i64, +}; + +const NodeKeyIndex = struct { + items: [*c]NodeKeyEntry = null, + count: usize = 0, +}; + +fn nodeKeyEntryCompare(left: ?*const anyopaque, right: ?*const anyopaque) callconv(.c) c_int { + return c.strcmp(cast(NodeKeyEntry, left).node_id, cast(NodeKeyEntry, right).node_id); +} + +fn nodeKeyIndexFree(index: *NodeKeyIndex) void { + for (many(index.items, index.count)) |*entry| c.free(entry.node_id); + c.free(index.items); + index.* = .{}; +} + +fn nodeKeyIndexBuild(db: ?*Db, workspace_id: Str, out: *NodeKeyIndex) bool { + out.* = .{}; + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (cbm_zova_workspace_graph_name(workspace_id, &graph_name, graph_name.len) != 0) return false; + nodeKeyIndexScan(db, bufZ(&graph_name), out) catch { + nodeKeyIndexFree(out); + return false; + }; + if (out.count != 0) c.qsort(out.items, out.count, @sizeOf(NodeKeyEntry), nodeKeyEntryCompare); + return true; +} + +fn nodeKeyIndexScan(db: ?*Db, graph_name: Str, out: *NodeKeyIndex) error{Failed}!void { + var cap: usize = 0; + var cursor: c.zova_graph_scan_cursor = .{}; + while (true) { + var page: c.zova_graph_scan_results = .{}; + defer c.zova_graph_scan_results_free(&page); + if (!statusOk(c.zova_graph_scan(&c.zova_graph_scan_request{ + .db = db, + .graph_name = graph_name, + .node_after = cursor, + .node_limit = 1024, + .edge_limit = 0, + .out_results = &page, + }), db, "nodes.scan")) return error.Failed; + const nodes = many(page.nodes, page.nodes_len); + for (nodes) |*node| { + if (node.node_id == null) continue; + if (out.count == cap) { + const next_cap = if (cap != 0) cap * 2 else 1024; + const grown = interop.reallocN(NodeKeyEntry, out.items, next_cap); + if (grown == null) return error.Failed; + out.items = grown; + cap = next_cap; + } + const node_id = zvStrdup(node.node_id); + if (node_id == null) return error.Failed; + out.items[out.count] = .{ .node_id = node_id, .node_key = node.node_key }; + out.count += 1; + } + if (nodes.len != 0) { + const last = &nodes[nodes.len - 1]; + cursor = .{ .created_order = last.created_order, .key = last.node_key }; + } + if (page.has_more_nodes == 0) break; + } +} + +/// The Zova node key of `node_id`; NULL when the index does not hold it. +fn nodeKeyIndexFind(index: *const NodeKeyIndex, node_id: CStr) ?i64 { + if (node_id == null or node_id[0] == 0 or index.count == 0) return null; + const probe: NodeKeyEntry = .{ .node_id = @constCast(node_id), .node_key = 0 }; + const found = c.bsearch(&probe, index.items, index.count, @sizeOf(NodeKeyEntry), nodeKeyEntryCompare) orelse return null; + const node_key = cast(NodeKeyEntry, found).node_key; + return if (node_key > 0) node_key else null; +} + +/// The Zova key of the (source, type, target) edge; NULL when it has none. +fn edgeKeyForIdentity(db: ?*Db, workspace_id: CStr, source_id: CStr, edge_type: CStr, target_id: CStr) ?i64 { + if (db == null or workspace_id == null or source_id == null or edge_type == null or target_id == null) return null; + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (cbm_zova_workspace_graph_name(workspace_id, &graph_name, graph_name.len) != 0) return null; + var neighbors: c.zova_graph_keyed_neighbor_results = .{}; + defer c.zova_graph_keyed_neighbor_results_free(&neighbors); + if (!statusOk(c.zova_graph_neighbors_keyed(&c.zova_graph_neighbors_keyed_request{ + .db = db, + .graph_name = &graph_name, + .node_id = source_id, + .direction = c.ZOVA_GRAPH_NEIGHBOR_OUTGOING, + .edge_type = edge_type, + .limit = std.math.maxInt(i64), + .out_results = &neighbors, + }), db, "edges.keyed_neighbors")) return null; + for (many(neighbors.items, neighbors.len)) |*neighbor| { + if (neighbor.node_id != null and c.strcmp(neighbor.node_id, target_id) == 0) { + return if (neighbor.edge_key > 0) neighbor.edge_key else null; + } + } + return null; +} + +// ── Workspace registry API ────────────────────────────────────── + +const RootLookup = enum { failed, missing, found }; + +/// Reads the workspace id registered for `root` into `out`. The phases logged +/// on failure are `phase` plus "_prepare", "_bind_root", "_step", "_column". +fn workspaceLookupRoot(db: ?*Db, root: Str, out: []u8, comptime phase: []const u8) RootLookup { + const stmt = prepareZova(db, "SELECT workspace_id FROM cbm_workspace_registry WHERE canonical_root = ?1", phase ++ "_prepare") orelse return .failed; + defer finalize(stmt); + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!workspaceBindText(db, stmt, 1, root, phase ++ "_bind_root") or + !statusOk(stepStatement(stmt, &step), db, phase ++ "_step")) return .failed; + if (step == c.ZOVA_STEP_ROW) { + return if (columnTextInto(db, stmt, 0, out, phase ++ "_column")) .found else .failed; + } + return if (step == c.ZOVA_STEP_DONE) .missing else .failed; +} + +pub export fn cbm_zova_workspace_get_or_create_at(registry_path: ?Str, root_path: ?Str, out_workspace_id: ?[*]u8, out_workspace_id_size: usize) c_int { + if (!with_zova) return -1; + var root_buf: [ZV_PATH_MAX]u8 = undefined; + const path = nonEmpty(registry_path) orelse return -1; + const out_ptr = out_workspace_id orelse return -1; + if (out_workspace_id_size < ID_MAX) return -1; + const root = workspaceNormalizeRoot(root_path, &root_buf) orelse return -1; + const out = out_ptr[0..out_workspace_id_size]; + out[0] = 0; + var zdb: ZovaDb = .{}; + defer closeZova(&zdb); + if (!openOrCreateZova(path, &zdb) or !workspaceRegistryInit(zdb.db) or !workspaceRegistryBegin(zdb.db)) return -1; + const db = zdb.db; + + var ok = workspaceLookupRoot(db, root.ptr, out, "workspace.lookup") != .failed; + if (ok and out[0] == 0) { + var workspace_id: [ID_MAX]u8 = undefined; + ok = workspaceIdFromRoot(root, &workspace_id) == 0; + if (ok) { + const stmt = prepareZova(db, "INSERT INTO cbm_workspace_registry" ++ + "(workspace_id,canonical_root,id_format_version,active_generation) " ++ + "VALUES(?1,?2,1,0)", "workspace.insert_prepare"); + ok = stmt != null and + workspaceBindText(db, stmt, 1, &workspace_id, "workspace.insert_bind_id") and + workspaceBindText(db, stmt, 2, root.ptr, "workspace.insert_bind_root") and + workspaceStepDone(db, stmt, "workspace.insert_step"); + finalize(stmt); + } + if (ok) _ = interop.bprint(out, "{s}", .{span(bufZ(&workspace_id))}); + } + ok = ok and workspaceRegistryCommit(db); + if (!ok) { + workspaceRegistryRollback(db); + out[0] = 0; + } + return if (ok) 0 else -1; +} + +/// The newest ready generation of the database; NULL when it has none. +fn sidecarGenerationReadDb(db: ?*Db) ?i64 { + if (db == null) return null; + const stmt = prepareZova(db, "SELECT MAX(generation) FROM cbm_database_generation_v1 " ++ + "WHERE state='ready'", "sidecar_generation.prepare") orelse return null; + defer finalize(stmt); + const generation = stepRowI64Logged(db, stmt, "sidecar_generation.step", "sidecar_generation.column") orelse return null; + return if (generation > 0) generation else null; +} + +/// The first int64 of the first row of `sql`, with `bind_value` bound to ?1 +/// when it is not NULL. +fn zovaQueryI64(db: ?*Db, sql: Str, bind_value: CStr, phase: Str) ?i64 { + if (db == null) return null; + const stmt = prepareZova(db, sql, phase) orelse return null; + defer finalize(stmt); + if (bind_value != null and !workspaceBindText(db, stmt, 1, bind_value, phase)) return null; + return stepRowI64Logged(db, stmt, phase, phase); +} + +pub export fn cbm_zova_workspace_lookup_at(registry_path: ?Str, root_path: ?Str, out_workspace_id: ?[*]u8, out_workspace_id_size: usize) c_int { + if (!with_zova) { + if (outBuf(out_workspace_id, out_workspace_id_size)) |out| out[0] = 0; + return -1; + } + var root_buf: [ZV_PATH_MAX]u8 = undefined; + const path = nonEmpty(registry_path) orelse return -1; + const out_ptr = out_workspace_id orelse return -1; + if (out_workspace_id_size < ID_MAX) return -1; + const root = workspaceNormalizeRoot(root_path, &root_buf) orelse return -1; + if (!c.cbm_file_exists(path)) return -1; + const out = out_ptr[0..out_workspace_id_size]; + out[0] = 0; + var zdb: ZovaDb = .{}; + if (!openZova(path, true, &zdb)) return -1; + defer closeZova(&zdb); + return if (workspaceLookupRoot(zdb.db, root.ptr, out, "workspace.lookup_read") == .found) 0 else -1; +} + +pub export fn cbm_zova_workspace_generation_begin_at(registry_path: ?Str, workspace_id: ?Str, generation: i64) c_int { + if (!with_zova) return -1; + const path = registry_path orelse return -1; + const id = nonEmpty(workspace_id) orelse return -1; + if (generation <= 0) return -1; + var zdb: ZovaDb = .{}; + defer closeZova(&zdb); + if (!openOrCreateZova(path, &zdb) or !workspaceRegistryInit(zdb.db) or + !workspaceLegacyGenerationInit(zdb.db) or !workspaceRegistryBegin(zdb.db)) return -1; + const db = zdb.db; + const stmt = prepareZova(db, "INSERT INTO cbm_workspace_generations(workspace_key,generation,state) " ++ + "SELECT workspace_key,?2,'building' FROM cbm_workspace_registry " ++ + "WHERE workspace_id=?1", "workspace.begin_prepare"); + var ok = stmt != null and + workspaceBindText(db, stmt, 1, id, "workspace.begin_bind_id") and + workspaceBindI64(db, stmt, 2, generation, "workspace.begin_bind_generation") and + workspaceStepDone(db, stmt, "workspace.begin_step"); + finalize(stmt); + ok = ok and workspaceRegistryCommit(db); + if (!ok) workspaceRegistryRollback(db); + return if (ok) 0 else -1; +} + +pub export fn cbm_zova_workspace_generation_finish_at(registry_path: ?Str, workspace_id: ?Str, generation: i64, ready: bool) c_int { + if (!with_zova) return -1; + const path = registry_path orelse return -1; + const id = nonEmpty(workspace_id) orelse return -1; + if (generation <= 0) return -1; + var zdb: ZovaDb = .{}; + defer closeZova(&zdb); + if (!openOrCreateZova(path, &zdb) or !workspaceRegistryInit(zdb.db) or + !workspaceLegacyGenerationInit(zdb.db) or !workspaceRegistryBegin(zdb.db)) return -1; + const db = zdb.db; + var state: [16]u8 = @splat(0); + var ok = workspaceQueryState(db, id, generation, &state) and c.strcmp(&state, "building") == 0; + if (ok) { + const stmt = prepareZova(db, "UPDATE cbm_workspace_generations SET state = ?1 " ++ + "WHERE workspace_key=(SELECT workspace_key FROM cbm_workspace_registry " ++ + "WHERE workspace_id=?2) AND generation=?3", "workspace.finish_prepare"); + ok = stmt != null and + workspaceBindText(db, stmt, 1, if (ready) "ready" else "failed", "workspace.finish_bind_state") and + workspaceBindText(db, stmt, 2, id, "workspace.finish_bind_id") and + workspaceBindI64(db, stmt, 3, generation, "workspace.finish_bind_generation") and + workspaceStepDone(db, stmt, "workspace.finish_step"); + finalize(stmt); + } + if (ok and ready) { + const stmt = prepareZova(db, "UPDATE cbm_workspace_registry SET active_generation = ?1 " ++ + "WHERE workspace_id = ?2", "workspace.active_prepare"); + ok = stmt != null and + workspaceBindI64(db, stmt, 1, generation, "workspace.active_bind_generation") and + workspaceBindText(db, stmt, 2, id, "workspace.active_bind_id") and + workspaceStepDone(db, stmt, "workspace.active_step"); + finalize(stmt); + } + ok = ok and workspaceRegistryCommit(db); + if (!ok) workspaceRegistryRollback(db); + return if (ok) 0 else -1; +} + +pub export fn cbm_zova_workspace_active_generation_at(registry_path: ?Str, workspace_id: ?Str, out_generation: ?*i64) c_int { + if (!with_zova) return -1; + const path = registry_path orelse return -1; + const id = nonEmpty(workspace_id) orelse return -1; + const out = out_generation orelse return -1; + out.* = 0; + var zdb: ZovaDb = .{}; + defer closeZova(&zdb); + if (!openOrCreateZova(path, &zdb) or !workspaceRegistryInit(zdb.db)) return -1; + const db = zdb.db; + const stmt = prepareZova(db, "SELECT active_generation FROM cbm_workspace_registry WHERE workspace_id = ?1", "workspace.active_get_prepare") orelse return -1; + defer finalize(stmt); + if (!workspaceBindText(db, stmt, 1, id, "workspace.active_get_bind_id")) return -1; + out.* = stepRowI64Logged(db, stmt, "workspace.active_get_step", "workspace.active_get_column") orelse return -1; + return 0; +} + +// ── Vector collections and graphs ─────────────────────────────── + +fn collectionDeleteIfExists(db: ?*Db, name: Str) bool { + var exists: u8 = 0; + if (!statusOk(c.zova_vector_collection_exists(&c.zova_vector_collection_exists_request{ + .db = db, + .name = name, + .out_exists = &exists, + }), db, "vector.exists")) return false; + if (exists == 0) return true; + return statusOk(c.zova_vector_collection_delete(&c.zova_vector_collection_delete_request{ .db = db, .name = name }), db, "vector.delete_collection"); +} + +fn collectionCreateI8(db: ?*Db, name: Str, vector_dim: c_int) bool { + if (!collectionDeleteIfExists(db, name)) return false; + return statusOk(c.zova_vector_collection_create(&c.zova_vector_collection_create_request{ + .db = db, + .name = name, + .options = .{ + .dimensions = @bitCast(vector_dim), + .metric = c.ZOVA_VECTOR_METRIC_COSINE, + .element_type = c.ZOVA_VECTOR_ELEMENT_TYPE_I8, + }, + }), db, "vector.create_collection"); +} + +const WorkspaceGraphNodeMap = extern struct { + sqlite_id: i64, + node_key: i64, + stable_id: [*c]u8, +}; + +/// The node map of one workspace generation, sorted by sqlite id. +const NodeMapList = struct { + items: [*c]WorkspaceGraphNodeMap = null, + count: usize = 0, + + fn slice(list: NodeMapList) []WorkspaceGraphNodeMap { + return many(list.items, list.count); + } +}; + +fn graphDeleteNamedIfExists(db: ?*Db, graph_name: Str) bool { + var exists: u8 = 0; + if (!statusOk(c.zova_graph_exists(&c.zova_graph_exists_request{ + .db = db, + .name = graph_name, + .out_exists = &exists, + }), db, "workspace_graph.exists")) return false; + if (exists == 0) return true; + return statusOk(c.zova_graph_delete(&c.zova_graph_delete_request{ .db = db, .name = graph_name }), db, "workspace_graph.delete"); +} + +fn workspaceGraphNodeMapFree(items: [*c]WorkspaceGraphNodeMap, count: usize) void { + for (many(items, count)) |*item| c.free(item.stable_id); + c.free(items); +} + +/// The stable id mapped to `sqlite_id`; NULL when the map does not hold it. +fn workspaceGraphNodeMapFind(items: []const WorkspaceGraphNodeMap, sqlite_id: i64) CStr { + var low: usize = 0; + var high: usize = items.len; + while (low < high) { + const mid = low + (high - low) / 2; + if (items[mid].sqlite_id == sqlite_id) return items[mid].stable_id; + if (items[mid].sqlite_id < sqlite_id) { + low = mid + 1; + } else { + high = mid; + } + } + return null; +} + +fn workspaceDumpNodeIdV2(workspace_id: Str, node: *const c.CBMDumpNode, out: []u8) bool { + if (node.label == null or node.name == null or node.qualified_name == null or node.file_path == null or + !workspaceNameComponentValid(workspace_id) or out.len == 0) return false; + var relative_path: [ZV_PATH_MAX]u8 = undefined; + if (interop.bprint(&relative_path, "{s}", .{span(node.file_path)}) >= relative_path.len) return false; + _ = c.cbm_normalize_path_sep(&relative_path); + var discriminator: [512]u8 = undefined; + if (node.qualified_name[0] != 0) { + if (interop.bprint(&discriminator, "named:{s}", .{span(node.qualified_name)}) >= discriminator.len) return false; + } else if (node.name[0] == 0 or node.start_line < 0 or node.end_line < node.start_line or + interop.bprint(&discriminator, "local:{s}:span:{d}:{d}", .{ span(node.name), node.start_line, node.end_line }) >= discriminator.len) + { + return false; + } + return cbm_zova_workspace_node_id_v2( + @ptrCast(workspace_id), + @ptrCast(node.label), + bufZ(&relative_path), + @ptrCast(node.qualified_name), + bufZ(&discriminator), + out.ptr, + out.len, + ) == 0; +} + +fn workspaceGraphNodeMapCompare(a: ?*const anyopaque, b: ?*const anyopaque) callconv(.c) c_int { + const left = cast(WorkspaceGraphNodeMap, a); + const right = cast(WorkspaceGraphNodeMap, b); + if (left.sqlite_id < right.sqlite_id) return -1; + if (left.sqlite_id > right.sqlite_id) return 1; + return c.strcmp(left.stable_id, right.stable_id); +} + +/// Builds the (sqlite id, stable id) map of `nodes`, sorted by sqlite id. +/// Fails when an id is not positive or two nodes share an id. +fn workspaceGenerationMapBuild(workspace_id: Str, nodes: [*c]const c.CBMDumpNode, node_count: c_int, out: *NodeMapList) bool { + out.* = .{}; + if (node_count < 0 or (node_count > 0 and nodes == null)) return false; + const count: usize = @intCast(node_count); + if (count == 0) return true; + const map = interop.callocN(WorkspaceGraphNodeMap, count); + if (map == null) return false; + for (nodes[0..count], map[0..count], 0..) |*node, *entry, i| { + var stable_id: [ID_MAX + 64]u8 = undefined; + if (node.id <= 0 or !workspaceDumpNodeIdV2(workspace_id, node, &stable_id)) { + workspaceGraphNodeMapFree(map, i); + return false; + } + entry.sqlite_id = node.id; + entry.stable_id = zvStrdup(&stable_id); + if (entry.stable_id == null) { + workspaceGraphNodeMapFree(map, i); + return false; + } + } + if (count > 1) c.qsort(map, count, @sizeOf(WorkspaceGraphNodeMap), workspaceGraphNodeMapCompare); + for (1..count) |i| { + if (map[i - 1].sqlite_id == map[i].sqlite_id or c.strcmp(map[i - 1].stable_id, map[i].stable_id) == 0) { + workspaceGraphNodeMapFree(map, count); + return false; + } + } + out.* = .{ .items = map, .count = count }; + return true; +} + +// ── Import of one workspace ───────────────────────────────────── + +fn userDatabaseResetStatement(stmt: ?*Stmt, phase: Str) bool { + if (stmt == null or c.zova_statement_reset(stmt) != c.ZOVA_OK or c.zova_statement_clear_bindings(stmt) != c.ZOVA_OK) { + interop.log(c.CBM_LOG_ERROR, "zova.user_import", .{ "phase", phase }); + return false; + } + return true; +} + +fn userDatabaseResetReboundStatement(stmt: ?*Stmt, phase: Str) bool { + if (stmt == null or c.zova_statement_reset(stmt) != c.ZOVA_OK) { + interop.log(c.CBM_LOG_ERROR, "zova.user_import", .{ "phase", phase }); + return false; + } + return true; +} + +fn userDatabaseClearWorkspace(db: ?*Db, workspace_id: Str) bool { + if (user_publish_delta_active) { + user_publish_test_metrics.delta_clear_violation_count += 1; + return false; + } + const workspace_key = workspaceKeyForId(db, workspace_id) orelse return false; + const statements = [_]Str{ + "DELETE FROM cbm_nodes_v1 WHERE workspace_key=?1", + "DELETE FROM cbm_file_hashes_v1 WHERE file_key IN " ++ + "(SELECT file_key FROM cbm_files_v1 WHERE workspace_key=?1)", + "DELETE FROM cbm_files_v1 WHERE workspace_key=?1", + "DELETE FROM cbm_token_vector_metadata_v1 WHERE workspace_key=?1", + "DELETE FROM cbm_project_summaries_v2 WHERE workspace_key=?1", + "DELETE FROM cbm_projects_v1 WHERE workspace_key=?1", + }; + for (statements) |sql| { + const stmt = prepareZova(db, sql, "user_import.clear_prepare") orelse return false; + defer finalize(stmt); + if (!workspaceBindI64(db, stmt, 1, workspace_key, "user_import.clear_bind") or + !workspaceStepDone(db, stmt, "user_import.clear_step")) return false; + } + return true; +} + +/// Puts every node into the workspace graph and writes its cbm_nodes_v1 row, +/// recording the Zova node key of each in `map`. +fn userDatabaseWriteNodesWithMap(db: ?*Db, workspace_id: Str, node_items: [*c]const c.CBMDumpNode, node_count: c_int, map_list: NodeMapList) bool { + if (node_count < 0) return false; + const count: usize = @intCast(node_count); + if ((count > 0 and (node_items == null or map_list.items == null)) or map_list.count != count) return false; + const nodes = many(node_items, count); + const map = map_list.slice(); + const workspace_key = workspaceKeyForId(db, workspace_id) orelse return false; + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + const native_keys = if (count != 0) interop.callocN(i64, count) else null; + defer c.free(native_keys); + { + const native = if (count != 0) interop.callocN(c.zova_graph_node_input, count) else null; + defer c.free(native); + if (cbm_zova_workspace_graph_name(@ptrCast(workspace_id), &graph_name, graph_name.len) != 0 or + (count != 0 and (native == null or native_keys == null))) return false; + for (nodes, map, 0..) |*node, *entry, i| { + native[i] = .{ + .graph_name = &graph_name, + .node_id = entry.stable_id, + .kind = node.label, + .target_type = c.ZOVA_GRAPH_TARGET_NONE, + .target_namespace = null, + .target_ref = null, + }; + } + if (count != 0 and !statusOk(c.zova_graph_node_put_many_keyed(&c.zova_graph_node_put_many_keyed_request{ + .db = db, + .nodes = native, + .nodes_len = count, + .out_node_keys = native_keys, + .out_node_keys_capacity = count, + }), db, "user_import.native_nodes")) return false; + } + const node_stmt = prepareZova(db, "INSERT INTO cbm_nodes_v1(zova_node_key,workspace_key,label,name,qualified_name," ++ + "file_key,start_line,end_line,properties,source_ordinal) " ++ + "VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10) " ++ + "ON CONFLICT(zova_node_key) DO UPDATE SET " ++ + "label=excluded.label,name=excluded.name,qualified_name=excluded.qualified_name," ++ + "file_key=excluded.file_key,start_line=excluded.start_line,end_line=excluded.end_line," ++ + "properties=excluded.properties,source_ordinal=excluded.source_ordinal", "user_import.nodes_prepare") orelse return false; + defer finalize(node_stmt); + for (nodes, map, 0..) |*node, *entry, i| { + if (node.id <= 0 or node.project == null or node.label == null or node.name == null or + node.qualified_name == null or node.file_path == null or + (i > 0 and nodes[i - 1].id >= node.id)) return false; + if (entry.sqlite_id != node.id or entry.stable_id == null or + !workspaceNameComponentValid(workspace_id)) return false; + + entry.node_key = native_keys[i]; + const file_key = fileKeyGetOrCreate(db, workspace_key, node.file_path) orelse return false; + if (!workspaceBindI64(db, node_stmt, 1, entry.node_key, "user_import.node_key") or + !workspaceBindI64(db, node_stmt, 2, workspace_key, "user_import.node_workspace") or + !workspaceBindText(db, node_stmt, 3, node.label, "user_import.node_label") or + !workspaceBindText(db, node_stmt, 4, node.name, "user_import.node_name") or + !workspaceBindText(db, node_stmt, 5, node.qualified_name, "user_import.node_qn") or + !workspaceBindI64(db, node_stmt, 6, file_key, "user_import.node_file") or + !workspaceBindI64(db, node_stmt, 7, node.start_line, "user_import.node_start") or + !workspaceBindI64(db, node_stmt, 8, node.end_line, "user_import.node_end") or + !workspaceBindText(db, node_stmt, 9, if (node.properties != null) node.properties else "{}", "user_import.node_properties") or + !workspaceBindI64(db, node_stmt, 10, @intCast(i), "user_import.node_source_ordinal") or + !workspaceStepDone(db, node_stmt, "user_import.node_step") or + !userDatabaseResetStatement(node_stmt, "user_import.node_reset")) return false; + } + return true; +} + +fn userDatabaseWriteNodes(db: ?*Db, workspace_id: Str, nodes: [*c]const c.CBMDumpNode, node_count: c_int, out_map: *NodeMapList) bool { + out_map.* = .{}; + var map: NodeMapList = .{}; + if (!workspaceGenerationMapBuild(workspace_id, nodes, node_count, &map)) return false; + if (!userDatabaseWriteNodesWithMap(db, workspace_id, nodes, node_count, map)) { + workspaceGraphNodeMapFree(map.items, map.count); + return false; + } + out_map.* = map; + return true; +} + +/// Puts every edge into the workspace graph, then replaces the payload of each +/// distinct native edge with the encoded group of dump edges that share it. +fn userDatabaseWriteEdges(db: ?*Db, workspace_id: Str, edge_items: [*c]const c.CBMDumpEdge, edge_count: c_int, map: NodeMapList) bool { + if (edge_count < 0 or (edge_count > 0 and edge_items == null)) return false; + const count: usize = @intCast(edge_count); + const edges = many(edge_items, count); + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (cbm_zova_workspace_graph_name(workspace_id, &graph_name, graph_name.len) != 0) return false; + if (count == 0) return true; + + const native_keys = interop.callocN(i64, count); + defer c.free(native_keys); + { + const native = interop.callocN(c.zova_graph_edge_input, count); + defer c.free(native); + if (native == null or native_keys == null) return false; + for (edges, 0..) |*edge, i| { + native[i] = .{ + .graph_name = &graph_name, + .from_node_id = workspaceGraphNodeMapFind(map.slice(), edge.source_id), + .edge_type = edge.type, + .to_node_id = workspaceGraphNodeMapFind(map.slice(), edge.target_id), + }; + } + if (!statusOk(c.zova_graph_edge_put_many_keyed(&c.zova_graph_edge_put_many_keyed_request{ + .db = db, + .edges = native, + .edges_len = count, + .out_edge_keys = native_keys, + .out_edge_keys_capacity = count, + }), db, "user_import.native_edges")) return false; + } + + const handled = interop.callocN(bool, count); + defer c.free(handled); + const group = interop.callocN([*c]const c.CBMDumpEdge, count); + defer c.free(@ptrCast(group)); + const replacements = interop.callocN(c.zova_graph_edge_payload_replacement, count); + defer c.free(replacements); + const owned_payloads = interop.callocN([*c]u8, count); + var replacement_count: usize = 0; + defer { + if (owned_payloads != null) { + for (owned_payloads[0..replacement_count]) |payload| c.free(payload); + } + c.free(@ptrCast(owned_payloads)); + } + if (handled == null or group == null or replacements == null or owned_payloads == null) return false; + + for (0..count) |i| { + if (handled[i]) continue; + if (native_keys[i] <= 0) return false; + var group_count: usize = 0; + for (i..count) |j| { + if (!handled[j] and native_keys[j] == native_keys[i]) { + handled[j] = true; + group[group_count] = &edges[j]; + group_count += 1; + } + } + var payload_len: usize = 0; + if (c.cbm_zova_edge_payload_encoded_size(group, group_count, &payload_len) != 0) return false; + const payload: [*c]u8 = if (payload_len > 0) interop.mallocN(u8, payload_len) else null; + if (payload_len > 0 and payload == null) return false; + var encoded: usize = 0; + if (c.cbm_zova_edge_payload_encode(group, group_count, payload, payload_len, &encoded) != 0 or + encoded != payload_len) + { + c.free(payload); + return false; + } + owned_payloads[replacement_count] = payload; + replacements[replacement_count] = .{ + .edge_key = native_keys[i], + .payload = payload, + .payload_len = payload_len, + }; + replacement_count += 1; + } + if (replacement_count == 0) return true; + return statusOk(c.zova_graph_edge_payload_replace_many(&c.zova_graph_edge_payload_replace_many_request{ + .db = db, + .graph_name = &graph_name, + .replacements = replacements, + .replacement_count = replacement_count, + }), db, "user_import.edge_payloads"); +} + +fn userDatabaseWriteSummary(db: ?*Db, workspace_id: Str, maybe_summary: ?*const c.cbm_zova_project_summary_input_t) bool { + const summary = maybe_summary orelse return true; + if (!summary.present) { + const stmt = prepareZova(db, "DELETE FROM cbm_project_summaries_v2 WHERE workspace_key=(SELECT " ++ + "workspace_key FROM cbm_workspace_registry WHERE workspace_id=?1)", "user_import.summary_delete_prepare") orelse return false; + defer finalize(stmt); + return workspaceBindText(db, stmt, 1, workspace_id, "user_import.summary_delete_bind") and + workspaceStepDone(db, stmt, "user_import.summary_delete_step"); + } + if (summary.summary == null or summary.source_hash == null or summary.created_at == null or summary.updated_at == null) return false; + const stmt = prepareZova(db, "INSERT INTO cbm_project_summaries_v2(workspace_key,summary,source_hash,created_at,updated_at) " ++ + "SELECT workspace_key,?2,?3,?4,?5 FROM cbm_workspace_registry WHERE workspace_id=?1 " ++ + "ON CONFLICT(workspace_key) DO UPDATE SET summary=excluded.summary," ++ + "source_hash=excluded.source_hash,created_at=excluded.created_at,updated_at=excluded.updated_at", "user_import.summary_prepare") orelse return false; + defer finalize(stmt); + return workspaceBindText(db, stmt, 1, workspace_id, "user_import.summary_workspace") and + workspaceBindText(db, stmt, 2, summary.summary, "user_import.summary_value") and + workspaceBindText(db, stmt, 3, summary.source_hash, "user_import.summary_hash") and + workspaceBindText(db, stmt, 4, summary.created_at, "user_import.summary_created") and + workspaceBindText(db, stmt, 5, summary.updated_at, "user_import.summary_updated") and + workspaceStepDone(db, stmt, "user_import.summary_step"); +} + +fn userImportProject(db: ?*Db, workspace_id: Str, project: Str, root_path: Str) bool { + const stmt = prepareZova(db, "INSERT INTO cbm_projects_v1(workspace_key,project,root_path) " ++ + "SELECT workspace_key,?2,?3 FROM cbm_workspace_registry WHERE workspace_id=?1 " ++ + "ON CONFLICT(workspace_key) DO UPDATE SET " ++ + "project=excluded.project,root_path=excluded.root_path,indexed_at=CURRENT_TIMESTAMP", "user_import.project_prepare") orelse return false; + defer finalize(stmt); + return workspaceBindText(db, stmt, 1, workspace_id, "user_import.project_workspace") and + workspaceBindText(db, stmt, 2, project, "user_import.project_name") and + workspaceBindText(db, stmt, 3, root_path, "user_import.project_root") and + workspaceStepDone(db, stmt, "user_import.project_step"); +} + +/// Replaces the stored graph of the workspace with `nodes` and `edges` in one +/// transaction. The caller owns the generation row. +fn userImportWorkspaceWrite( + zova_path: Str, + workspace_id: Str, + root_path: Str, + project: Str, + nodes: [*c]const c.CBMDumpNode, + node_count: c_int, + edges: [*c]const c.CBMDumpEdge, + edge_count: c_int, +) bool { + var zdb: ZovaDb = .{}; + defer closeZova(&zdb); + if (!openOrCreateZova(zova_path, &zdb) or !workspaceRegistryBegin(zdb.db)) return false; + const db = zdb.db; + var map: NodeMapList = .{}; + defer workspaceGraphNodeMapFree(map.items, map.count); + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + const ok = userDatabaseClearWorkspace(db, workspace_id) and + cbm_zova_workspace_graph_name(workspace_id, &graph_name, graph_name.len) == 0 and + graphDeleteNamedIfExists(db, bufZ(&graph_name)) and + statusOk(c.zova_graph_create(&c.zova_graph_create_request{ .db = db, .name = &graph_name }), db, "user_import.graph_create") and + userImportProject(db, workspace_id, project, root_path) and + userDatabaseWriteNodes(db, workspace_id, nodes, node_count, &map) and + !testFailPhase("user_after_nodes") and + userDatabaseWriteEdges(db, workspace_id, edges, edge_count, map) and + !testFailPhase("user_after_edges") and + !testFailPhase("user_before_commit") and + workspaceRegistryCommit(db); + if (!ok) workspaceRegistryRollback(db); + return ok; +} + +pub export fn cbm_zova_user_database_import_workspace( + zova_path: ?Str, + root_path: ?Str, + project: ?Str, + generation: i64, + nodes: [*c]const c.CBMDumpNode, + node_count: c_int, + edges: [*c]const c.CBMDumpEdge, + edge_count: c_int, +) c_int { + if (!with_zova) return -1; + const path = nonEmpty(zova_path) orelse return -1; + const root = nonEmpty(root_path) orelse return -1; + const name = nonEmpty(project) orelse return -1; + if (generation <= 0 or node_count < 0 or edge_count < 0 or + (node_count > 0 and nodes == null) or (edge_count > 0 and edges == null)) return -1; + var workspace_id: [ID_MAX]u8 = @splat(0); + if (cbm_zova_user_database_generation_begin(path, root, generation, &workspace_id, workspace_id.len) != 0) return -1; + const id = bufZ(&workspace_id); + + if (!userImportWorkspaceWrite(path, id, root, name, nodes, node_count, edges, edge_count) or + cbm_zova_user_database_generation_finish(path, id, generation, true) != 0) + { + _ = cbm_zova_user_database_generation_finish(path, id, generation, false); + return -1; + } + return 0; +} + +pub export fn cbm_zova_workspace_generation_digest_input(workspace_id: ?Str, input: ?*const GenerationInput, out_result: ?*GenerationResult) c_int { + if (!with_zova) { + if (out_result) |out| out.* = std.mem.zeroes(GenerationResult); + return -1; + } + const id = workspace_id orelse return -1; + const in = input orelse return -1; + const out = out_result orelse return -1; + var model: ?*PublishModel = null; + if (c.cbm_zova_publish_model_build(id, in, &model) != 0) return -1; + defer c.cbm_zova_publish_model_free(model); + const digests = c.cbm_zova_publish_model_digests(model); + if (digests == null) return -1; + out.* = digests.*; + return 0; +} + +// ── Publication: registry and bookkeeping rows ────────────────── + +/// Derives the workspace id of `root_path` into `out_id` and registers the +/// workspace when the registry does not hold it yet. +fn userWorkspaceIdTx(db: ?*Db, root_path: ?Str, out_id: []u8) bool { + var root_buf: [ZV_PATH_MAX]u8 = undefined; + const root = workspaceNormalizeRoot(root_path, &root_buf) orelse return false; + if (workspaceIdFromRoot(root, out_id) != 0) return false; + if (!workspaceRegistryInit(db)) return false; + const stmt = prepareZova(db, "INSERT INTO cbm_workspace_registry(workspace_id,canonical_root,id_format_version,active_generation) " ++ + "VALUES(?1,?2,1,0) ON CONFLICT(canonical_root) DO NOTHING", "user_publish.workspace_prepare") orelse return false; + defer finalize(stmt); + return workspaceBindText(db, stmt, 1, out_id.ptr, "user_publish.workspace_id") and + workspaceBindText(db, stmt, 2, root.ptr, "user_publish.workspace_root") and + workspaceStepDone(db, stmt, "user_publish.workspace_step"); +} + +fn userPublishGenerationStateTx(db: ?*Db, workspace_id: Str, generation: i64, ready: bool) bool { + { + const stmt = prepareZova(db, "UPDATE cbm_database_generation_v1 SET state=?1 WHERE workspace_key=" ++ + "(SELECT workspace_key FROM cbm_workspace_registry WHERE workspace_id=?2) " ++ + "AND generation=?3", "user_publish.generation_update_prepare") orelse return false; + defer finalize(stmt); + if (!workspaceBindText(db, stmt, 1, if (ready) "ready" else "failed", "user_publish.generation_state") or + !workspaceBindText(db, stmt, 2, workspace_id, "user_publish.generation_workspace") or + !workspaceBindI64(db, stmt, 3, generation, "user_publish.generation_id") or + !workspaceStepDone(db, stmt, "user_publish.generation_step")) return false; + } + if (!ready) return true; + const stmt = prepareZova(db, "UPDATE cbm_workspace_registry SET active_generation=?1 WHERE workspace_id=?2", "user_publish.active_prepare") orelse return false; + defer finalize(stmt); + return workspaceBindI64(db, stmt, 1, generation, "user_publish.active_generation") and + workspaceBindText(db, stmt, 2, workspace_id, "user_publish.active_workspace") and + workspaceStepDone(db, stmt, "user_publish.active_step"); +} + +fn userPublishClearHealthTx(db: ?*Db, workspace_id: Str) bool { + const statement = prepareZova(db, "DELETE FROM cbm_workspace_health_v1 WHERE workspace_key=(SELECT " ++ + "workspace_key FROM cbm_workspace_registry WHERE workspace_id=?1)", "user_publish.health_prepare") orelse return false; + defer finalize(statement); + return workspaceBindText(db, statement, 1, workspace_id, "user_publish.health_workspace") and + workspaceStepDone(db, statement, "user_publish.health_step"); +} + +fn userPublishInsertGenerationTx(db: ?*Db, workspace_id: Str, generation: i64) bool { + const stmt = prepareZova(db, "INSERT INTO cbm_database_generation_v1(workspace_key,generation,state) " ++ + "SELECT workspace_key,?2,'building' FROM cbm_workspace_registry WHERE workspace_id=?1", "user_publish.generation_insert_prepare") orelse return false; + defer finalize(stmt); + return workspaceBindText(db, stmt, 1, workspace_id, "user_publish.generation_insert_workspace") and + workspaceBindI64(db, stmt, 2, generation, "user_publish.generation_insert_id") and + workspaceStepDone(db, stmt, "user_publish.generation_insert_step"); +} + +fn userPublishProjectTx(db: ?*Db, workspace_id: Str, root_path: CStr, project: CStr, indexed_at: CStr) bool { + const stmt = prepareZova(db, "INSERT INTO cbm_projects_v1(workspace_key,project,root_path,indexed_at) " ++ + "SELECT workspace_key,?2,?3,?4 FROM cbm_workspace_registry WHERE workspace_id=?1 " ++ + "ON CONFLICT(workspace_key) DO UPDATE SET " ++ + "project=excluded.project,root_path=excluded.root_path,indexed_at=excluded.indexed_at", "user_publish.project_prepare") orelse return false; + defer finalize(stmt); + return workspaceBindText(db, stmt, 1, workspace_id, "user_publish.project_workspace") and + workspaceBindText(db, stmt, 2, project, "user_publish.project_name") and + workspaceBindText(db, stmt, 3, root_path, "user_publish.project_root") and + workspaceBindText(db, stmt, 4, indexed_at, "user_publish.project_indexed_at") and + workspaceStepDone(db, stmt, "user_publish.project_step"); +} + +fn userPublishWriteIndexStateTx(db: ?*Db, workspace_id: Str, generation: i64, model: CStr, dimensions: c_int, indexed_at: CStr) bool { + const stmt = prepareZova(db, "INSERT INTO cbm_workspace_index_state_v1" ++ + "(workspace_key,generation,model_fingerprint,vector_dimensions,indexed_at) " ++ + "SELECT workspace_key,?2,?3,?4,?5 FROM cbm_workspace_registry WHERE workspace_id=?1 " ++ + "ON CONFLICT(workspace_key) DO UPDATE SET " ++ + "generation=excluded.generation,model_fingerprint=excluded.model_fingerprint," ++ + "vector_dimensions=excluded.vector_dimensions,indexed_at=excluded.indexed_at", "user_publish.index_state_prepare") orelse return false; + defer finalize(stmt); + return workspaceBindText(db, stmt, 1, workspace_id, "user_publish.index_state_workspace") and + workspaceBindI64(db, stmt, 2, generation, "user_publish.index_state_generation") and + workspaceBindText(db, stmt, 3, model, "user_publish.index_state_model") and + workspaceBindI64(db, stmt, 4, dimensions, "user_publish.index_state_dimensions") and + workspaceBindText(db, stmt, 5, indexed_at, "user_publish.index_state_indexed_at") and + workspaceStepDone(db, stmt, "user_publish.index_state_step"); +} + +/// The number of vectors the collection holds; NULL when it cannot be read. +fn vectorCollectionCount(db: ?*Db, collection: Str, phase: Str) ?u64 { + var info: c.zova_vector_collection_info = .{}; + defer c.zova_vector_collection_info_free(&info); + if (!statusOk(c.zova_vector_collection_info_get(&c.zova_vector_collection_info_get_request{ + .db = db, + .name = collection, + .out_info = &info, + }), db, phase)) return null; + return info.vector_count; +} + +fn userPublishNativeReadbackCountsTx(db: ?*Db, result: *const GenerationResult, node_collection: Str, token_collection: Str) bool { + if (db == null) return false; + const node_vectors = vectorCollectionCount(db, node_collection, "user_publish.readback.node_collection") orelse return false; + if (node_vectors != result.node_vectors) return false; + const token_vectors = vectorCollectionCount(db, token_collection, "user_publish.readback.token_collection") orelse return false; + return token_vectors == result.token_vectors; +} + +fn userPublishIntegrityTx(db: ?*Db, workspace_id: Str, generation: i64, result: *const GenerationResult) bool { + const stmt = prepareZova(db, "INSERT INTO cbm_generation_integrity_v2" ++ + "(workspace_key,generation,graph_nodes,graph_edges,metadata_nodes,metadata_edges," ++ + "metadata_topology_edges,fts_rows,node_vector_rows,token_vector_rows,node_vectors," ++ + "token_vectors,metadata_sha256,fts_sha256,topology_sha256,node_vector_sha256," ++ + "token_vector_sha256) SELECT workspace_key,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12," ++ + "?13,?14,?15,?16,?17 FROM cbm_workspace_registry WHERE workspace_id=?1", "user_publish.integrity_prepare") orelse return false; + defer finalize(stmt); + return workspaceBindText(db, stmt, 1, workspace_id, "user_publish.integrity_workspace") and + workspaceBindI64(db, stmt, 2, generation, "user_publish.integrity_generation") and + workspaceBindI64(db, stmt, 3, @bitCast(result.graph_nodes), "user_publish.integrity_graph_nodes") and + workspaceBindI64(db, stmt, 4, @bitCast(result.graph_edges), "user_publish.integrity_graph_edges") and + workspaceBindI64(db, stmt, 5, @bitCast(result.metadata_nodes), "user_publish.integrity_metadata_nodes") and + workspaceBindI64(db, stmt, 6, @bitCast(result.metadata_edges), "user_publish.integrity_metadata_edges") and + workspaceBindI64(db, stmt, 7, @bitCast(result.metadata_topology_edges), "user_publish.integrity_topology_edges") and + workspaceBindI64(db, stmt, 8, @bitCast(result.fts_rows), "user_publish.integrity_fts_rows") and + workspaceBindI64(db, stmt, 9, @bitCast(result.node_vector_rows), "user_publish.integrity_node_vector_rows") and + workspaceBindI64(db, stmt, 10, @bitCast(result.token_vector_rows), "user_publish.integrity_token_vector_rows") and + workspaceBindI64(db, stmt, 11, @bitCast(result.node_vectors), "user_publish.integrity_node_vectors") and + workspaceBindI64(db, stmt, 12, @bitCast(result.token_vectors), "user_publish.integrity_token_vectors") and + workspaceBindText(db, stmt, 13, &result.metadata_sha256, "user_publish.integrity_metadata_digest") and + workspaceBindText(db, stmt, 14, &result.fts_sha256, "user_publish.integrity_fts_digest") and + workspaceBindText(db, stmt, 15, &result.topology_sha256, "user_publish.integrity_topology_digest") and + workspaceBindText(db, stmt, 16, &result.node_vector_sha256, "user_publish.integrity_node_vector_digest") and + workspaceBindText(db, stmt, 17, &result.token_vector_sha256, "user_publish.integrity_token_vector_digest") and + workspaceStepDone(db, stmt, "user_publish.integrity_step"); +} + +fn userPublishElapsedMs(start: *const c.struct_timespec, end: *const c.struct_timespec) f64 { + return @as(f64, @floatFromInt(end.tv_sec - start.tv_sec)) * 1000.0 + + @as(f64, @floatFromInt(end.tv_nsec - start.tv_nsec)) / 1000000.0; +} + +fn monotonicNow() c.struct_timespec { + var now = std.mem.zeroes(c.struct_timespec); + _ = c.cbm_clock_gettime(c.CLOCK_MONOTONIC, &now); + return now; +} + +/// The milliseconds since `mark`, which moves to now. Logs the phase when +/// CBM_ZOVA_PUBLISH_PROFILE is "1". +fn userPublishProfilePhase(phase: Str, maybe_mark: ?*c.struct_timespec) f64 { + const mark = maybe_mark orelse return 0.0; + const now = monotonicNow(); + const elapsed_ms = userPublishElapsedMs(mark, &now); + const enabled: CStr = c.getenv("CBM_ZOVA_PUBLISH_PROFILE"); + if (enabled != null and c.strcmp(enabled, "1") == 0) { + var elapsed: [64]u8 = undefined; + _ = interop.bprint(&elapsed, "{d:.3}", .{elapsed_ms}); + interop.log(c.CBM_LOG_INFO, "zova.single_file_publish.phase", .{ "phase", phase, "elapsed_ms", bufZ(&elapsed) }); + } + mark.* = now; + return elapsed_ms; +} + +// ── Publication: private keys ─────────────────────────────────── + +const UserFileKey = extern struct { + path: CStr = null, + file_key: i64 = 0, +}; + +const UserNodeKey = extern struct { + stable_id: CStr = null, + node_key: i64 = 0, +}; + +const UserTokenKey = extern struct { + token: CStr = null, + token_key: i64 = 0, +}; + +const UserTopologyKey = extern struct { + source_stable_id: CStr = null, + edge_type: CStr = null, + target_stable_id: CStr = null, + edge_key: i64 = 0, +}; + +/// A calloc'ed array and its length. The owner frees `items`. +fn List(comptime T: type) type { + return struct { + items: [*c]T = null, + count: usize = 0, + + fn slice(list: @This()) []T { + return many(list.items, list.count); + } + }; +} + +/// calloc of `count` items; NULL for a count of 0. +fn callocList(comptime T: type, count: usize) [*c]T { + return if (count != 0) interop.callocN(T, count) else null; +} + +/// A count the C API returns as an int; a negative one counts as 0. +fn countOf(count: c_int) usize { + return if (count > 0) @intCast(count) else 0; +} + +/// (size_t)value, as C converts it. +fn sizeOfInt(value: c_int) usize { + return @bitCast(@as(isize, value)); +} + +/// One int8 vector of `dimensions` values stored under `id`. +fn i8VectorInput(id: CStr, vector: [*c]const u8, dimensions: c_int) c.zova_vector_input { + return .{ + .id = id, + .values = .{ + .element_type = c.ZOVA_VECTOR_ELEMENT_TYPE_I8, + .i8_values = @ptrCast(vector), + .values_len = sizeOfInt(dimensions), + }, + }; +} + +fn userNodeKeyCompare(left: ?*const anyopaque, right: ?*const anyopaque) callconv(.c) c_int { + return c.strcmp(cast(UserNodeKey, left).stable_id, cast(UserNodeKey, right).stable_id); +} + +fn userTokenKeyCompare(left: ?*const anyopaque, right: ?*const anyopaque) callconv(.c) c_int { + return c.strcmp(cast(UserTokenKey, left).token, cast(UserTokenKey, right).token); +} + +fn userStringPointerCompare(left: ?*const anyopaque, right: ?*const anyopaque) callconv(.c) c_int { + return c.strcmp(cast(CStr, left).*, cast(CStr, right).*); +} + +/// The key of the entry named `name` in `items`, which are sorted by name; +/// 0 when no entry has that name. +fn namedKeyFind(comptime T: type, comptime name_field: []const u8, comptime key_field: []const u8, items: []const T, name: CStr) i64 { + var low: usize = 0; + var high: usize = items.len; + while (low < high) { + const mid = low + (high - low) / 2; + const order = c.strcmp(@field(items[mid], name_field), name); + if (order == 0) return @field(items[mid], key_field); + if (order < 0) { + low = mid + 1; + } else { + high = mid; + } + } + return 0; +} + +fn userFileKeyFind(items: []const UserFileKey, path: CStr) i64 { + return namedKeyFind(UserFileKey, "path", "file_key", items, path); +} + +fn userNodeKeyFind(items: []const UserNodeKey, stable_id: CStr) i64 { + return namedKeyFind(UserNodeKey, "stable_id", "node_key", items, stable_id); +} + +fn userTokenKeyFind(items: []const UserTokenKey, token: CStr) i64 { + return namedKeyFind(UserTokenKey, "token", "token_key", items, token); +} + +fn userTopologyIdentityCompare(row: *const c.cbm_zova_publish_edge_t, source: CStr, edge_type: CStr, target: CStr) c_int { + var order = c.strcmp(row.source_stable_id, source); + if (order == 0) order = c.strcmp(row.source.*.type, edge_type); + if (order == 0) order = c.strcmp(row.target_stable_id, target); + return order; +} + +fn userPublishModelTopologyFind(model: ?*const PublishModel, source: CStr, edge_type: CStr, target: CStr) ?*const c.cbm_zova_publish_edge_t { + var low: c_int = 0; + var high: c_int = c.cbm_zova_publish_model_topology_count(model); + while (low < high) { + const middle = low + @divTrunc(high - low, 2); + const row: *const c.cbm_zova_publish_edge_t = c.cbm_zova_publish_model_topology_at(model, middle) orelse return null; + const order = userTopologyIdentityCompare(row, source, edge_type, target); + if (order < 0) { + low = middle + 1; + } else if (order > 0) { + high = middle; + } else { + return row; + } + } + return null; +} + +/// Writes a private key as the decimal id Zova vectors are stored under. +fn userPrivateKeyId(key: i64, out: *[32]u8) bool { + return key > 0 and interop.bprint(out, "{d}", .{key}) < out.len; +} + +/// The `index`-th buffer of an array of private key ids. +fn idAt(ids: [*c][32]u8, index: usize) *[32]u8 { + return ids + index; +} + +/// Sorts `paths` and moves the distinct ones to the front; returns them. +fn sortUniquePaths(paths: []CStr) []CStr { + if (paths.len > 1) c.qsort(@ptrCast(paths.ptr), paths.len, @sizeOf(CStr), userStringPointerCompare); + var unique_count: usize = 0; + for (paths, 0..) |path, i| { + if (i == 0 or c.strcmp(paths[i - 1], path) != 0) { + paths[unique_count] = path; + unique_count += 1; + } + } + return paths[0..unique_count]; +} + +/// The file key of every path the delta inserts, updates or re-hashes, +/// sorted by path. Creates the cbm_files_v1 row of a path that has none. +fn userDeltaFileKeysTx(db: ?*Db, workspace_key: i64, delta: ?*const WorkspaceDelta, metrics: *const DeltaMetrics, out: *List(UserFileKey)) bool { + out.* = .{}; + const capacity64 = metrics.node_inserts + metrics.node_updates + metrics.file_hash_upserts; + if (capacity64 > INT_MAX) return false; + const capacity: usize = @intCast(capacity64); + const paths = callocList(CStr, capacity); + defer c.free(@ptrCast(paths)); + if (capacity != 0 and paths == null) return false; + var path_count: usize = 0; + for (0..@intCast(metrics.node_inserts)) |i| { + const row = c.cbm_zova_workspace_delta_node_insert_at(delta, @intCast(i)); + if (row == null or row.*.source == null) return false; + paths[path_count] = row.*.source.*.file_path; + path_count += 1; + } + for (0..@intCast(metrics.node_updates)) |i| { + const row = c.cbm_zova_workspace_delta_node_update_at(delta, @intCast(i)); + if (row == null or row.*.source == null) return false; + paths[path_count] = row.*.source.*.file_path; + path_count += 1; + } + for (0..@intCast(metrics.file_hash_upserts)) |i| { + const row = c.cbm_zova_workspace_delta_file_hash_upsert_at(delta, @intCast(i)); + if (row == null) return false; + paths[path_count] = row.*.file_path; + path_count += 1; + } + const unique = sortUniquePaths(many(paths, path_count)); + const items = callocList(UserFileKey, unique.len); + if (unique.len != 0 and items == null) return false; + for (unique, 0..) |path, i| { + items[i].path = path; + items[i].file_key = fileKeyGetOrCreate(db, workspace_key, path) orelse { + c.free(items); + return false; + }; + user_publish_test_metrics.delta_file_key_resolutions += 1; + } + out.* = .{ .items = items, .count = unique.len }; + return true; +} + +/// Inserts a cbm_files_v1 row for every path of the model and returns their +/// file keys, sorted by path. +fn userPublishFileKeysTx(db: ?*Db, model: ?*const PublishModel, workspace_key: i64, out: *List(UserFileKey)) bool { + out.* = .{}; + const input = c.cbm_zova_publish_model_input(model); + const hash_count = c.cbm_zova_publish_model_file_hash_count(model); + const capacity = countOf(input.*.node_count + hash_count); + const paths = callocList(CStr, capacity); + defer c.free(@ptrCast(paths)); + if (capacity != 0 and paths == null) return false; + var path_count: usize = 0; + for (many(input.*.nodes, input.*.node_count)) |*node| { + paths[path_count] = node.file_path; + path_count += 1; + } + for (0..countOf(hash_count)) |i| { + paths[path_count] = c.cbm_zova_publish_model_file_hash_at(model, @intCast(i)).*.file_path; + path_count += 1; + } + const unique = sortUniquePaths(many(paths, path_count)); + if (unique.len == 0) return true; + const items = callocList(UserFileKey, unique.len); + if (items == null) return false; + var done = false; + defer if (!done) c.free(items); + const next_key = nextPrivateKey(db, "SELECT COALESCE(MAX(file_key),0)+1 FROM cbm_files_v1", "user_publish.next_file_key") orelse return false; + const statement = prepareZova(db, "INSERT INTO cbm_files_v1(file_key,workspace_key,file_path) " ++ + "VALUES(?1,?2,?3)", "user_publish.files_prepare") orelse return false; + defer finalize(statement); + for (unique, 0..) |path, i| { + items[i] = .{ .path = path, .file_key = next_key + @as(i64, @intCast(i)) }; + if (!workspaceBindI64(db, statement, 1, items[i].file_key, "user_publish.file_key") or + !workspaceBindI64(db, statement, 2, workspace_key, "user_publish.file_workspace") or + !workspaceBindText(db, statement, 3, items[i].path, "user_publish.file_path") or + !workspaceStepDone(db, statement, "user_publish.file_step") or + !userDatabaseResetReboundStatement(statement, "user_publish.file_reset")) return false; + userPublishMetricRow(&user_publish_test_metrics.canonical_files_sql, 2, 1, 0, false); + } + done = true; + out.* = .{ .items = items, .count = unique.len }; + return true; +} + +// ── Publication: the rows of a whole model ────────────────────── + +fn userPublishModelNodesTx(db: ?*Db, model: ?*const PublishModel, workspace_key: i64, file_keys: []const UserFileKey, node_keys: []const UserNodeKey) bool { + user_publish_test_metrics.canonical_node_fts_passes += 1; + const count = countOf(c.cbm_zova_publish_model_node_count(model)); + const node = prepareZova(db, "INSERT INTO cbm_nodes_v1(zova_node_key,workspace_key,label,name,qualified_name," ++ + "file_key,start_line,end_line,properties,source_ordinal) " ++ + "VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)", "user_publish.model_nodes_prepare") orelse return false; + defer finalize(node); + for (0..count) |i| { + const row = c.cbm_zova_publish_model_node_at(model, @intCast(i)); + if (row == null or row.*.source == null) return false; + const source = row.*.source; + const file_key = userFileKeyFind(file_keys, source.*.file_path); + if (file_key <= 0 or row.*.ordinal != i) return false; + if (i >= node_keys.len or c.strcmp(node_keys[i].stable_id, row.*.stable_id) != 0 or + node_keys[i].node_key <= 0) return false; + if (!workspaceBindI64(db, node, 1, node_keys[i].node_key, "user_publish.model_node_key") or + !workspaceBindI64(db, node, 2, workspace_key, "user_publish.model_node_ws") or + !workspaceBindText(db, node, 3, source.*.label, "user_publish.model_node_label") or + !workspaceBindText(db, node, 4, source.*.name, "user_publish.model_node_name") or + !workspaceBindText(db, node, 5, source.*.qualified_name, "user_publish.model_node_qn") or + !workspaceBindI64(db, node, 6, file_key, "user_publish.model_node_file") or + !workspaceBindI64(db, node, 7, source.*.start_line, "user_publish.model_node_start") or + !workspaceBindI64(db, node, 8, source.*.end_line, "user_publish.model_node_end") or + !workspaceBindText(db, node, 9, if (source.*.properties != null) source.*.properties else "{}", "user_publish.model_node_properties") or + !workspaceBindI64(db, node, 10, @bitCast(row.*.source_ordinal), "user_publish.model_node_source_ordinal") or + !workspaceStepDone(db, node, "user_publish.model_node_step") or + !userDatabaseResetReboundStatement(node, "user_publish.model_node_reset")) return false; + userPublishMetricRow(&user_publish_test_metrics.canonical_nodes_sql, 6, 4, 0, false); + } + return true; +} + +fn userPublishModelHashesTx(db: ?*Db, model: ?*const PublishModel, file_keys: []const UserFileKey) bool { + const statement = prepareZova(db, "INSERT INTO cbm_file_hashes_v1(file_key,content_hash,mtime_ns,size_bytes) " ++ + "VALUES(?1,?2,?3,?4)", "user_publish.model_hash_prepare") orelse return false; + defer finalize(statement); + const count = countOf(c.cbm_zova_publish_model_file_hash_count(model)); + for (0..count) |i| { + const row = c.cbm_zova_publish_model_file_hash_at(model, @intCast(i)); + if (row == null) return false; + const file_key = userFileKeyFind(file_keys, row.*.file_path); + if (file_key <= 0) return false; + if (!workspaceBindI64(db, statement, 1, file_key, "user_publish.model_hash_file") or + !workspaceBindText(db, statement, 2, row.*.content_hash, "user_publish.model_hash_value") or + !workspaceBindI64(db, statement, 3, row.*.mtime_ns, "user_publish.model_hash_mtime") or + !workspaceBindI64(db, statement, 4, row.*.size_bytes, "user_publish.model_hash_size") or + !workspaceStepDone(db, statement, "user_publish.model_hash_step") or + !userDatabaseResetReboundStatement(statement, "user_publish.model_hash_reset")) return false; + userPublishMetricRow(&user_publish_test_metrics.canonical_hashes_sql, 3, 1, 0, false); + } + return true; +} + +/// Inserts the metadata row of every token vector and returns the token +/// keys, in model order. +fn userPublishModelTokenMetadataTx(db: ?*Db, model: ?*const PublishModel, workspace_key: i64, out: *List(UserTokenKey)) bool { + out.* = .{}; + const count = countOf(c.cbm_zova_publish_model_token_vector_count(model)); + if (count == 0) return true; + const token_keys = callocList(UserTokenKey, count); + if (token_keys == null) return false; + var done = false; + defer if (!done) c.free(token_keys); + const next_key = nextPrivateKey(db, "SELECT COALESCE(MAX(token_key),0)+1 FROM cbm_token_vector_metadata_v1", "user_publish.next_token_key") orelse return false; + const statement = prepareZova(db, "INSERT INTO cbm_token_vector_metadata_v1(token_key,workspace_key,token,idf) " ++ + "VALUES(?1,?2,?3,?4)", "user_publish.model_token_prepare") orelse return false; + defer finalize(statement); + for (0..count) |i| { + const row = c.cbm_zova_publish_model_token_vector_at(model, @intCast(i)); + if (row == null or row.*.source == null or row.*.ordinal != i) return false; + token_keys[i] = .{ .token = row.*.source.*.token, .token_key = next_key + @as(i64, @intCast(i)) }; + if (!workspaceBindI64(db, statement, 1, token_keys[i].token_key, "user_publish.model_token_key") or + !workspaceBindI64(db, statement, 2, workspace_key, "user_publish.model_token_ws") or + !workspaceBindText(db, statement, 3, row.*.source.*.token, "user_publish.model_token_value") or + !statusOk(c.zova_statement_bind_double(&c.zova_statement_bind_double_request{ + .statement = statement, + .index = 4, + .value = row.*.source.*.idf, + }), db, "user_publish.model_token_idf") or + !workspaceStepDone(db, statement, "user_publish.model_token_step") or + !userDatabaseResetReboundStatement(statement, "user_publish.model_token_reset")) return false; + userPublishMetricRow(&user_publish_test_metrics.canonical_token_metadata_sql, 2, 1, 1, false); + } + done = true; + out.* = .{ .items = token_keys, .count = count }; + return true; +} + +const UserNativeGraphProfile = struct { + materialize_ms: f64 = 0, + reset_ms: f64 = 0, + nodes_ms: f64 = 0, + edges_ms: f64 = 0, + validate_ms: f64 = 0, + key_generation_ms: f64 = 0, + cleanup_ms: f64 = 0, +}; + +/// Rebuilds the native graph of the model's workspace and returns the Zova +/// key of every node and topology edge, in model order. An empty database +/// takes the one-call fresh build; otherwise nodes, edges and payloads go in +/// separately. +fn userPublishModelGraphTx( + db: ?*Db, + model: ?*const PublishModel, + out_node_keys: *[*c]UserNodeKey, + out_topology_keys: *[*c]UserTopologyKey, + out_nodes: ?*u64, + out_edges: ?*u64, + profile: *UserNativeGraphProfile, +) bool { + out_node_keys.* = null; + out_topology_keys.* = null; + profile.* = .{}; + var profile_mark = monotonicNow(); + const workspace_id = c.cbm_zova_publish_model_workspace_id(model); + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (cbm_zova_workspace_graph_name(@ptrCast(workspace_id), &graph_name, graph_name.len) != 0) { + profile.materialize_ms = userPublishProfilePhase("native_graph.materialize", &profile_mark); + return false; + } + const node_count = countOf(c.cbm_zova_publish_model_node_count(model)); + const edge_count = countOf(c.cbm_zova_publish_model_topology_count(model)); + // Declared first so it runs after the arrays below are released. + defer profile.cleanup_ms = userPublishProfilePhase("native_graph.cleanup", &profile_mark); + const nodes = callocList(c.zova_graph_fresh_node_input, node_count); + defer c.free(nodes); + const edges = callocList(c.zova_graph_fresh_edge_payload_input, edge_count); + defer c.free(edges); + const node_key_values = callocList(i64, node_count); + defer c.free(node_key_values); + const edge_key_values = callocList(i64, edge_count); + defer c.free(edge_key_values); + const node_keys = callocList(UserNodeKey, node_count); + const topology_keys = callocList(UserTopologyKey, edge_count); + var ok = false; + defer if (!ok) { + c.free(node_keys); + c.free(topology_keys); + }; + if ((node_count != 0 and (nodes == null or node_key_values == null or node_keys == null)) or + (edge_count != 0 and (edges == null or edge_key_values == null or topology_keys == null))) + { + profile.materialize_ms = userPublishProfilePhase("native_graph.materialize", &profile_mark); + return false; + } + for (0..node_count) |i| { + const row = c.cbm_zova_publish_model_node_at(model, @intCast(i)); + nodes[i] = .{ + .node_id = row.*.stable_id, + .kind = row.*.source.*.label, + .target_type = c.ZOVA_GRAPH_TARGET_NONE, + .target_namespace = null, + .target_ref = null, + }; + node_keys[i].stable_id = row.*.stable_id; + } + for (0..edge_count) |i| { + const row = c.cbm_zova_publish_model_topology_at(model, @intCast(i)); + edges[i] = .{ + .from_node_ordinal = @intCast(row.*.source_ordinal), + .edge_type = row.*.source.*.type, + .to_node_ordinal = @intCast(row.*.target_ordinal), + .payload = row.*.payload, + .payload_len = row.*.payload_len, + }; + topology_keys[i] = .{ + .source_stable_id = row.*.source_stable_id, + .edge_type = row.*.source.*.type, + .target_stable_id = row.*.target_stable_id, + }; + } + profile.materialize_ms = userPublishProfilePhase("native_graph.materialize", &profile_mark); + ok = graphDeleteNamedIfExists(db, bufZ(&graph_name)); + var use_fresh_build = false; + if (ok) { + var graph_list: c.zova_graph_list = .{}; + defer c.zova_graph_list_free(&graph_list); + ok = statusOk(c.zova_graphs_list(&c.zova_graph_list_request{ .db = db, .out_list = &graph_list }), db, "user_publish.model_graph_list"); + if (ok) use_fresh_build = graph_list.len == 0; + } + if (ok and !use_fresh_build) { + ok = statusOk(c.zova_graph_create(&c.zova_graph_create_request{ .db = db, .name = &graph_name }), db, "user_publish.model_graph_create"); + } + profile.reset_ms = userPublishProfilePhase("native_graph.reset", &profile_mark); + if (ok and use_fresh_build) { + user_publish_test_metrics.native_graph_fresh_calls += 1; + user_publish_test_metrics.native_graph_prepared_calls += 1; + ok = statusOk(c.zova_graph_build_fresh_prepared_keyed_with_payloads(&c.zova_graph_build_fresh_prepared_keyed_with_payloads_request{ + .db = db, + .graph_name = &graph_name, + .nodes = nodes, + .nodes_len = node_count, + .edges = edges, + .edges_len = edge_count, + .out_node_keys = node_key_values, + .out_node_keys_capacity = node_count, + .out_edge_keys = edge_key_values, + .out_edge_keys_capacity = edge_count, + }), db, "user_publish.model_graph_fresh_prepared"); + if (ok and testFailPhase("user_after_graph_nodes")) ok = false; + if (ok and testFailPhase("user_after_graph_edges")) ok = false; + // Fresh construction is one atomic Zova call, so it has no truthful + // node/edge timing boundary. Keep the node phase empty and report the + // complete bulk-build call in the edge phase used by existing reports. + profile.nodes_ms = 0.0; + profile.edges_ms = userPublishProfilePhase("native_graph.edges", &profile_mark); + } else if (ok) { + const legacy_nodes = callocList(c.zova_graph_node_input, node_count); + defer c.free(legacy_nodes); + const legacy_edges = callocList(c.zova_graph_edge_input, edge_count); + defer c.free(legacy_edges); + if ((node_count != 0 and legacy_nodes == null) or (edge_count != 0 and legacy_edges == null)) ok = false; + if (ok) { + for (0..node_count) |i| { + const row = c.cbm_zova_publish_model_node_at(model, @intCast(i)); + legacy_nodes[i] = .{ + .graph_name = &graph_name, + .node_id = row.*.stable_id, + .kind = row.*.source.*.label, + .target_type = c.ZOVA_GRAPH_TARGET_NONE, + .target_namespace = null, + .target_ref = null, + }; + } + for (0..edge_count) |i| { + const row = c.cbm_zova_publish_model_topology_at(model, @intCast(i)); + legacy_edges[i] = .{ + .graph_name = &graph_name, + .from_node_id = row.*.source_stable_id, + .edge_type = row.*.source.*.type, + .to_node_id = row.*.target_stable_id, + }; + } + } + if (ok and node_count != 0) { + user_publish_test_metrics.native_graph_node_calls += 1; + ok = statusOk(c.zova_graph_node_put_many_keyed(&c.zova_graph_node_put_many_keyed_request{ + .db = db, + .nodes = legacy_nodes, + .nodes_len = node_count, + .out_node_keys = node_key_values, + .out_node_keys_capacity = node_count, + }), db, "user_publish.model_graph_nodes"); + } + if (ok and testFailPhase("user_after_graph_nodes")) ok = false; + profile.nodes_ms = userPublishProfilePhase("native_graph.nodes", &profile_mark); + if (ok and edge_count != 0) { + user_publish_test_metrics.native_graph_edge_calls += 1; + ok = statusOk(c.zova_graph_edge_put_many_keyed(&c.zova_graph_edge_put_many_keyed_request{ + .db = db, + .edges = legacy_edges, + .edges_len = edge_count, + .out_edge_keys = edge_key_values, + .out_edge_keys_capacity = edge_count, + }), db, "user_publish.model_graph_edges"); + } + if (ok and edge_count != 0) { + const replacements = interop.callocN(c.zova_graph_edge_payload_replacement, edge_count); + defer c.free(replacements); + ok = replacements != null; + if (ok) { + for (0..edge_count) |i| { + const row = c.cbm_zova_publish_model_topology_at(model, @intCast(i)); + replacements[i] = .{ + .edge_key = edge_key_values[i], + .payload = row.*.payload, + .payload_len = row.*.payload_len, + }; + } + ok = statusOk(c.zova_graph_edge_payload_replace_many(&c.zova_graph_edge_payload_replace_many_request{ + .db = db, + .graph_name = &graph_name, + .replacements = replacements, + .replacement_count = edge_count, + }), db, "user_publish.model_graph_payloads"); + } + } + if (ok and testFailPhase("user_after_graph_edges")) ok = false; + profile.edges_ms = userPublishProfilePhase("native_graph.edges", &profile_mark); + } + if (ok) { + for (0..node_count) |i| { + if (node_key_values[i] <= 0) { + ok = false; + break; + } + node_keys[i].node_key = node_key_values[i]; + } + } + if (ok) { + for (0..edge_count) |i| { + if (edge_key_values[i] <= 0) { + ok = false; + break; + } + topology_keys[i].edge_key = edge_key_values[i]; + } + } + if (ok) { + var info: c.zova_graph_info = .{}; + defer c.zova_graph_info_free(&info); + ok = statusOk(c.zova_graph_info_get(&c.zova_graph_info_get_request{ + .db = db, + .name = &graph_name, + .out_info = &info, + }), db, "user_publish.model_graph_info"); + if (ok) { + if (out_nodes) |out| out.* = info.node_count; + if (out_edges) |out| out.* = info.edge_count; + } + profile.validate_ms = userPublishProfilePhase("native_graph.validate", &profile_mark); + } + if (ok) { + out_node_keys.* = node_keys; + out_topology_keys.* = topology_keys; + } + return ok; +} + +fn userPublishModelVectorsTx( + db: ?*Db, + model: ?*const PublishModel, + node_collection: Str, + token_collection: Str, + node_keys: []const UserNodeKey, + token_keys: []const UserTokenKey, +) bool { + const input = c.cbm_zova_publish_model_input(model); + const node_count = countOf(c.cbm_zova_publish_model_node_vector_count(model)); + const token_count = countOf(c.cbm_zova_publish_model_token_vector_count(model)); + const nodes = callocList(c.zova_vector_input, node_count); + defer c.free(nodes); + const tokens = callocList(c.zova_vector_input, token_count); + defer c.free(tokens); + const node_ids = callocList([32]u8, node_count); + defer c.free(node_ids); + const token_ids = callocList([32]u8, token_count); + defer c.free(token_ids); + if ((node_count != 0 and (nodes == null or node_ids == null)) or + (token_count != 0 and (tokens == null or token_ids == null))) return false; + for (0..node_count) |i| { + const row = c.cbm_zova_publish_model_node_vector_at(model, @intCast(i)); + if (row == null or row.*.ordinal != i or row.*.node_ordinal >= node_keys.len) return false; + const node_key = &node_keys[@intCast(row.*.node_ordinal)]; + if (node_key.node_key <= 0 or c.strcmp(node_key.stable_id, row.*.stable_id) != 0 or + !userPrivateKeyId(node_key.node_key, idAt(node_ids, i))) return false; + nodes[i] = i8VectorInput(idAt(node_ids, i), row.*.source.*.vector, input.*.vector_dimensions); + } + for (0..token_count) |i| { + const row = c.cbm_zova_publish_model_token_vector_at(model, @intCast(i)); + if (row == null or row.*.source == null or row.*.ordinal != i or i >= token_keys.len) return false; + const key = token_keys[i].token_key; + if (key <= 0 or c.strcmp(token_keys[i].token, row.*.source.*.token) != 0 or + !userPrivateKeyId(key, idAt(token_ids, i))) return false; + tokens[i] = i8VectorInput(idAt(token_ids, i), row.*.source.*.vector, input.*.vector_dimensions); + } + if (!collectionCreateI8(db, node_collection, input.*.vector_dimensions) or + !collectionCreateI8(db, token_collection, input.*.vector_dimensions)) return false; + if (node_count != 0) { + user_publish_test_metrics.native_node_vector_calls += 1; + if (!statusOk(c.zova_vector_put_many(&c.zova_vector_put_many_request{ + .db = db, + .collection_name = node_collection, + .vectors = nodes, + .vectors_len = node_count, + }), db, "user_publish.model_node_vectors")) return false; + } + if (testFailPhase("user_after_node_vectors")) return false; + if (token_count != 0) { + user_publish_test_metrics.native_token_vector_calls += 1; + if (!statusOk(c.zova_vector_put_many(&c.zova_vector_put_many_request{ + .db = db, + .collection_name = token_collection, + .vectors = tokens, + .vectors_len = token_count, + }), db, "user_publish.model_token_vectors")) return false; + } + return !testFailPhase("user_after_token_vectors"); +} + +// ── Publication: delta ────────────────────────────────────────── + +const DeltaIdAt = *const fn (?*const WorkspaceDelta, c_int) callconv(.c) [*c]const u8; + +/// Runs `sql` (?1 workspace key, ?2 id) once per id `item_at` returns. +fn userDeltaDeleteIdsTx(db: ?*Db, workspace_key: i64, sql: Str, phase: Str, delta: ?*const WorkspaceDelta, item_at: DeltaIdAt, count: u64) bool { + if (count == 0) return true; + const statement = prepareZova(db, sql, phase) orelse return false; + defer finalize(statement); + for (0..@intCast(count)) |i| { + const id = item_at(delta, @intCast(i)); + if (id == null) return false; + if (!workspaceBindI64(db, statement, 1, workspace_key, phase) or + !workspaceBindText(db, statement, 2, id, phase) or + !workspaceStepDone(db, statement, phase) or + !userDatabaseResetStatement(statement, phase)) return false; + } + return true; +} + +/// Deletes the vectors, token metadata, graph edges and graph nodes the +/// delta removes. +fn userDeltaDeleteNativeTx( + db: ?*Db, + workspace_id: Str, + node_index: *const NodeKeyIndex, + node_collection: Str, + token_collection: Str, + delta: ?*const WorkspaceDelta, + metrics: *const DeltaMetrics, +) bool { + { + const node_vector_deletes: usize = @intCast(metrics.node_vector_deletes); + const token_vector_deletes: usize = @intCast(metrics.token_vector_deletes); + const node_vectors = callocList(CStr, node_vector_deletes); + defer c.free(@ptrCast(node_vectors)); + const token_vectors = callocList(CStr, token_vector_deletes); + defer c.free(@ptrCast(token_vectors)); + const node_vector_ids = callocList([32]u8, node_vector_deletes); + defer c.free(node_vector_ids); + const token_vector_ids = callocList([32]u8, token_vector_deletes); + defer c.free(token_vector_ids); + if ((node_vector_deletes != 0 and (node_vectors == null or node_vector_ids == null)) or + (token_vector_deletes != 0 and (token_vectors == null or token_vector_ids == null))) return false; + const workspace_key = workspaceKeyForId(db, workspace_id) orelse return false; + for (0..node_vector_deletes) |i| { + const stable_id = c.cbm_zova_workspace_delta_node_vector_delete_at(delta, @intCast(i)); + if (stable_id == null) return false; + const node_key = nodeKeyIndexFind(node_index, stable_id) orelse return false; + if (!userPrivateKeyId(node_key, idAt(node_vector_ids, i))) return false; + node_vectors[i] = idAt(node_vector_ids, i); + } + if (token_vector_deletes != 0) { + const token_key_statement = prepareZova(db, "SELECT token_key FROM cbm_token_vector_metadata_v1 " ++ + "WHERE workspace_key=?1 AND token=?2", "user_delta.token_key_prepare") orelse return false; + defer finalize(token_key_statement); + for (0..token_vector_deletes) |i| { + const token = c.cbm_zova_workspace_delta_token_vector_delete_at(delta, @intCast(i)); + if (c.zova_statement_reset(token_key_statement) != c.ZOVA_OK or + c.zova_statement_clear_bindings(token_key_statement) != c.ZOVA_OK or + !workspaceBindI64(db, token_key_statement, 1, workspace_key, "user_delta.token_key_ws") or + !workspaceBindText(db, token_key_statement, 2, token, "user_delta.token_key_token")) return false; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + var token_key: i64 = 0; + if (!statusOk(stepStatement(token_key_statement, &step), db, "user_delta.token_key_step") or + step != c.ZOVA_STEP_ROW or + !statusOk(columnI64(token_key_statement, 0, &token_key), db, "user_delta.token_key_value") or + !userPrivateKeyId(token_key, idAt(token_vector_ids, i))) return false; + token_vectors[i] = idAt(token_vector_ids, i); + } + } + if (node_vector_deletes != 0 and !statusOk(c.zova_vector_delete_many(&c.zova_vector_delete_many_request{ + .db = db, + .collection_name = node_collection, + .vector_ids = node_vectors, + .vector_count = node_vector_deletes, + }), db, "user_delta.node_vector_delete")) return false; + if (token_vector_deletes != 0 and !statusOk(c.zova_vector_delete_many(&c.zova_vector_delete_many_request{ + .db = db, + .collection_name = token_collection, + .vector_ids = token_vectors, + .vector_count = token_vector_deletes, + }), db, "user_delta.token_vector_delete")) return false; + if (token_vector_deletes != 0 and !userDeltaDeleteIdsTx( + db, + workspace_key, + "DELETE FROM cbm_token_vector_metadata_v1 WHERE workspace_key=?1 AND token=?2", + "user_delta.token_delete", + delta, + c.cbm_zova_workspace_delta_token_vector_delete_at, + metrics.token_vector_deletes, + )) return false; + } + if (testFailPhase("user_delta_after_vector_deletes")) return false; + + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (cbm_zova_workspace_graph_name(workspace_id, &graph_name, graph_name.len) != 0) return false; + const topology_deletes: usize = @intCast(metrics.topology_deletes); + const node_deletes: usize = @intCast(metrics.node_deletes); + const edges = callocList(c.zova_graph_edge_input, topology_deletes); + defer c.free(edges); + const nodes = callocList(CStr, node_deletes); + defer c.free(@ptrCast(nodes)); + if ((topology_deletes != 0 and edges == null) or (node_deletes != 0 and nodes == null)) return false; + for (0..topology_deletes) |i| { + const edge = c.cbm_zova_workspace_delta_topology_delete_at(delta, @intCast(i)); + if (edge == null) return false; + edges[i] = .{ + .graph_name = &graph_name, + .from_node_id = edge.*.source_stable_id, + .edge_type = edge.*.edge_type, + .to_node_id = edge.*.target_stable_id, + }; + } + for (0..node_deletes) |i| nodes[i] = c.cbm_zova_workspace_delta_node_delete_at(delta, @intCast(i)); + if (topology_deletes != 0 and !statusOk(c.zova_graph_edge_delete_many(&c.zova_graph_edge_delete_many_request{ + .db = db, + .edges = edges, + .edges_len = topology_deletes, + }), db, "user_delta.graph_edge_delete")) return false; + if (node_deletes != 0 and !statusOk(c.zova_graph_node_delete_many(&c.zova_graph_node_delete_many_request{ + .db = db, + .graph_name = &graph_name, + .node_ids = nodes, + .node_count = node_deletes, + }), db, "user_delta.graph_node_delete")) return false; + return !testFailPhase("user_delta_after_graph_deletes"); +} + +/// Deletes the cbm_nodes_v1 and cbm_file_hashes_v1 rows the delta removes. +fn userDeltaDeleteCanonicalTx(db: ?*Db, workspace_key: i64, node_index: *const NodeKeyIndex, delta: ?*const WorkspaceDelta, metrics: *const DeltaMetrics) bool { + if (metrics.node_deletes != 0) { + const node_delete = prepareZova(db, "DELETE FROM cbm_nodes_v1 WHERE workspace_key=?1 AND zova_node_key=?2", "user_delta.node_delete_prepare") orelse return false; + defer finalize(node_delete); + for (0..@intCast(metrics.node_deletes)) |i| { + const stable_id = c.cbm_zova_workspace_delta_node_delete_at(delta, @intCast(i)); + if (stable_id == null) return false; + const node_key = nodeKeyIndexFind(node_index, stable_id) orelse return false; + if (!workspaceBindI64(db, node_delete, 1, workspace_key, "user_delta.node_delete_ws") or + !workspaceBindI64(db, node_delete, 2, node_key, "user_delta.node_delete_key") or + !workspaceStepDone(db, node_delete, "user_delta.node_delete_step") or + !userDatabaseResetStatement(node_delete, "user_delta.node_delete_reset")) return false; + } + } + if (!userDeltaDeleteIdsTx( + db, + workspace_key, + "DELETE FROM cbm_file_hashes_v1 WHERE file_key=(SELECT file_key FROM cbm_files_v1 " ++ + "WHERE workspace_key=?1 AND file_path=?2)", + "user_delta.hash_delete", + delta, + c.cbm_zova_workspace_delta_file_hash_delete_at, + metrics.file_hash_deletes, + )) return false; + return !testFailPhase("user_delta_after_canonical_deletes"); +} + +/// The node the delta inserts or updates at `index`: inserts come first. +fn deltaNodeUpsertAt(delta: ?*const WorkspaceDelta, metrics: *const DeltaMetrics, index: usize) [*c]const c.cbm_zova_publish_node_t { + const inserts: usize = @intCast(metrics.node_inserts); + return if (index < inserts) + c.cbm_zova_workspace_delta_node_insert_at(delta, @intCast(index)) + else + c.cbm_zova_workspace_delta_node_update_at(delta, @intCast(index - inserts)); +} + +fn userDeltaUpsertNodesTx( + db: ?*Db, + workspace_key: i64, + delta: ?*const WorkspaceDelta, + metrics: *const DeltaMetrics, + file_keys: []const UserFileKey, + node_keys: []const UserNodeKey, +) bool { + const total: usize = @intCast(metrics.node_inserts + metrics.node_updates); + if (total == 0) return true; + const node = prepareZova(db, "INSERT INTO cbm_nodes_v1(zova_node_key,workspace_key,label,name,qualified_name,file_key," ++ + "start_line,end_line,properties,source_ordinal) " ++ + "VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10) " ++ + "ON CONFLICT(zova_node_key) DO UPDATE SET " ++ + "label=excluded.label,name=excluded.name,qualified_name=excluded.qualified_name," ++ + "file_key=excluded.file_key,start_line=excluded.start_line,end_line=excluded.end_line," ++ + "properties=excluded.properties,source_ordinal=excluded.source_ordinal", "user_delta.node_upsert_prepare") orelse return false; + defer finalize(node); + for (0..total) |i| { + const row = deltaNodeUpsertAt(delta, metrics, i); + const source = row.*.source; + const node_key = userNodeKeyFind(node_keys, row.*.stable_id); + const file_key = userFileKeyFind(file_keys, source.*.file_path); + if (node_key <= 0 or file_key <= 0) return false; + if (!workspaceBindI64(db, node, 1, node_key, "user_delta.node_key") or + !workspaceBindI64(db, node, 2, workspace_key, "user_delta.node_ws") or + !workspaceBindText(db, node, 3, source.*.label, "user_delta.node_label") or + !workspaceBindText(db, node, 4, source.*.name, "user_delta.node_name") or + !workspaceBindText(db, node, 5, source.*.qualified_name, "user_delta.node_qn") or + !workspaceBindI64(db, node, 6, file_key, "user_delta.node_file") or + !workspaceBindI64(db, node, 7, source.*.start_line, "user_delta.node_start") or + !workspaceBindI64(db, node, 8, source.*.end_line, "user_delta.node_end") or + !workspaceBindText(db, node, 9, if (source.*.properties != null) source.*.properties else "{}", "user_delta.node_props") or + !workspaceBindI64(db, node, 10, @bitCast(row.*.source_ordinal), "user_delta.node_source_ordinal") or + !workspaceStepDone(db, node, "user_delta.node_step") or + !userDatabaseResetStatement(node, "user_delta.node_reset")) return false; + } + return true; +} + +/// The token key of every token vector the delta upserts, sorted by token: +/// the stored key of a known token, a new key otherwise. +fn userDeltaTokenKeysTx(db: ?*Db, workspace_key: i64, delta: ?*const WorkspaceDelta, metrics: *const DeltaMetrics, out: *List(UserTokenKey)) bool { + out.* = .{}; + const count: usize = @intCast(metrics.token_vector_upserts); + if (count == 0) return true; + const keys = interop.callocN(UserTokenKey, count); + if (keys == null) return false; + var done = false; + defer if (!done) c.free(keys); + const next_key = nextPrivateKey(db, "SELECT COALESCE(MAX(token_key),0)+1 FROM cbm_token_vector_metadata_v1", "user_delta.next_token_key") orelse return false; + { + const statement = prepareZova(db, "SELECT token_key FROM cbm_token_vector_metadata_v1 " ++ + "WHERE workspace_key=?1 AND token=?2", "user_delta.token_lookup_prepare") orelse return false; + defer finalize(statement); + var allocated: i64 = 0; + for (0..count) |i| { + const row = c.cbm_zova_workspace_delta_token_vector_upsert_at(delta, @intCast(i)); + if (row == null or row.*.source == null or row.*.source.*.token == null) return false; + if (c.zova_statement_reset(statement) != c.ZOVA_OK or + c.zova_statement_clear_bindings(statement) != c.ZOVA_OK or + !workspaceBindI64(db, statement, 1, workspace_key, "user_delta.token_lookup_ws") or + !workspaceBindText(db, statement, 2, row.*.source.*.token, "user_delta.token_lookup_token")) return false; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!statusOk(stepStatement(statement, &step), db, "user_delta.token_lookup_step")) return false; + var key: i64 = 0; + if (step == c.ZOVA_STEP_ROW) { + if (!statusOk(columnI64(statement, 0, &key), db, "user_delta.token_lookup_value")) return false; + } else if (step == c.ZOVA_STEP_DONE) { + key = next_key + allocated; + allocated += 1; + } else { + return false; + } + if (key <= 0) return false; + keys[i] = .{ .token = row.*.source.*.token, .token_key = key }; + } + } + if (count > 1) c.qsort(keys, count, @sizeOf(UserTokenKey), userTokenKeyCompare); + done = true; + out.* = .{ .items = keys, .count = count }; + return true; +} + +/// Puts the nodes, edges, edge payloads and vectors the delta inserts or +/// updates. Returns the Zova key of every put node, sorted by stable id. +fn userDeltaPutNativeTx( + db: ?*Db, + workspace_id: Str, + node_index: *const NodeKeyIndex, + node_collection: Str, + token_collection: Str, + delta: ?*const WorkspaceDelta, + metrics: *const DeltaMetrics, + model: ?*const PublishModel, + out_node_keys: *List(UserNodeKey), + token_keys: []const UserTokenKey, +) bool { + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (cbm_zova_workspace_graph_name(workspace_id, &graph_name, graph_name.len) != 0) return false; + const node_count: usize = @intCast(metrics.node_inserts + metrics.node_updates); + const edge_inserts: usize = @intCast(metrics.edge_inserts); + const node_keys = callocList(UserNodeKey, node_count); + var done = false; + defer if (!done) c.free(node_keys); + { + const nodes = callocList(c.zova_graph_node_input, node_count); + defer c.free(nodes); + const edges = callocList(c.zova_graph_edge_input, edge_inserts); + defer c.free(edges); + const node_key_values = callocList(i64, node_count); + defer c.free(node_key_values); + const edge_keys = callocList(i64, edge_inserts); + defer c.free(edge_keys); + if ((node_count != 0 and (nodes == null or node_key_values == null or node_keys == null)) or + (edge_inserts != 0 and (edges == null or edge_keys == null))) return false; + for (0..node_count) |i| { + const row = deltaNodeUpsertAt(delta, metrics, i); + nodes[i] = .{ + .graph_name = &graph_name, + .node_id = row.*.stable_id, + .kind = row.*.source.*.label, + .target_type = c.ZOVA_GRAPH_TARGET_NONE, + .target_namespace = null, + .target_ref = null, + }; + node_keys[i].stable_id = row.*.stable_id; + } + for (0..edge_inserts) |i| { + const row = c.cbm_zova_workspace_delta_edge_insert_at(delta, @intCast(i)); + edges[i] = .{ + .graph_name = &graph_name, + .from_node_id = row.*.source_stable_id, + .edge_type = row.*.source.*.type, + .to_node_id = row.*.target_stable_id, + }; + } + if (node_count != 0 and !statusOk(c.zova_graph_node_put_many_keyed(&c.zova_graph_node_put_many_keyed_request{ + .db = db, + .nodes = nodes, + .nodes_len = node_count, + .out_node_keys = node_key_values, + .out_node_keys_capacity = node_count, + }), db, "user_delta.graph_nodes")) return false; + for (0..node_count) |i| { + node_keys[i].node_key = node_key_values[i]; + if (node_keys[i].node_key <= 0) return false; + } + if (node_count > 1) c.qsort(node_keys, node_count, @sizeOf(UserNodeKey), userNodeKeyCompare); + if (edge_inserts != 0 and !statusOk(c.zova_graph_edge_put_many_keyed(&c.zova_graph_edge_put_many_keyed_request{ + .db = db, + .edges = edges, + .edges_len = edge_inserts, + .out_edge_keys = edge_keys, + .out_edge_keys_capacity = edge_inserts, + }), db, "user_delta.graph_edges")) return false; + for (0..edge_inserts) |i| { + if (edge_keys[i] <= 0) return false; + } + + // Every topology edge that gained or lost a logical edge gets its + // payload replaced. + const topology_count = countOf(c.cbm_zova_publish_model_topology_count(model)); + const affected = callocList(bool, topology_count); + defer c.free(affected); + const replacements = callocList(c.zova_graph_edge_payload_replacement, topology_count); + defer c.free(replacements); + if (topology_count != 0 and (affected == null or replacements == null)) return false; + for (0..edge_inserts) |i| { + const row = c.cbm_zova_workspace_delta_edge_insert_at(delta, @intCast(i)); + if (row == null or row.*.topology_ordinal >= topology_count) return false; + affected[@intCast(row.*.topology_ordinal)] = true; + } + for (0..@intCast(metrics.edge_deletes)) |i| { + const deleted = c.cbm_zova_workspace_delta_edge_delete_at(delta, @intCast(i)); + if (deleted == null) return false; + if (userPublishModelTopologyFind(model, deleted.*.source_stable_id, deleted.*.edge_type, deleted.*.target_stable_id)) |row| { + if (row.ordinal >= topology_count) return false; + affected[@intCast(row.ordinal)] = true; + } + } + var replacement_count: usize = 0; + for (0..topology_count) |i| { + if (!affected[i]) continue; + const row = c.cbm_zova_publish_model_topology_at(model, @intCast(i)); + if (row == null) return false; + const edge_key = edgeKeyForIdentity(db, workspace_id, row.*.source_stable_id, row.*.source.*.type, row.*.target_stable_id) orelse return false; + if (edge_key <= 0) return false; + replacements[replacement_count] = .{ + .edge_key = edge_key, + .payload = row.*.payload, + .payload_len = row.*.payload_len, + }; + replacement_count += 1; + } + if (replacement_count > 0 and !statusOk(c.zova_graph_edge_payload_replace_many(&c.zova_graph_edge_payload_replace_many_request{ + .db = db, + .graph_name = &graph_name, + .replacements = replacements, + .replacement_count = replacement_count, + }), db, "user_delta.graph_payloads")) return false; + } + if (testFailPhase("user_delta_after_graph_puts")) return false; + + const input = c.cbm_zova_publish_model_input(model); + const node_vector_upserts: usize = @intCast(metrics.node_vector_upserts); + const token_vector_upserts: usize = @intCast(metrics.token_vector_upserts); + const node_vectors = callocList(c.zova_vector_input, node_vector_upserts); + defer c.free(node_vectors); + const token_vectors = callocList(c.zova_vector_input, token_vector_upserts); + defer c.free(token_vectors); + const node_vector_ids = callocList([32]u8, node_vector_upserts); + defer c.free(node_vector_ids); + const token_vector_ids = callocList([32]u8, token_vector_upserts); + defer c.free(token_vector_ids); + if ((node_vector_upserts != 0 and (node_vectors == null or node_vector_ids == null)) or + (token_vector_upserts != 0 and (token_vectors == null or token_vector_ids == null))) return false; + for (0..node_vector_upserts) |i| { + const row = c.cbm_zova_workspace_delta_node_vector_upsert_at(delta, @intCast(i)); + // A node this delta put has its key in `node_keys`; any other node + // kept the key the index read before the graph writes. + const put_key = userNodeKeyFind(many(node_keys, node_count), row.*.stable_id); + const key = if (put_key > 0) put_key else (nodeKeyIndexFind(node_index, row.*.stable_id) orelse return false); + if (!userPrivateKeyId(key, idAt(node_vector_ids, i))) return false; + node_vectors[i] = i8VectorInput(idAt(node_vector_ids, i), row.*.source.*.vector, input.*.vector_dimensions); + } + for (0..token_vector_upserts) |i| { + const row = c.cbm_zova_workspace_delta_token_vector_upsert_at(delta, @intCast(i)); + const key = userTokenKeyFind(token_keys, row.*.source.*.token); + if (!userPrivateKeyId(key, idAt(token_vector_ids, i))) return false; + token_vectors[i] = i8VectorInput(idAt(token_vector_ids, i), row.*.source.*.vector, input.*.vector_dimensions); + } + if (node_vector_upserts != 0 and !statusOk(c.zova_vector_put_many(&c.zova_vector_put_many_request{ + .db = db, + .collection_name = node_collection, + .vectors = node_vectors, + .vectors_len = node_vector_upserts, + }), db, "user_delta.node_vectors")) return false; + if (token_vector_upserts != 0 and !statusOk(c.zova_vector_put_many(&c.zova_vector_put_many_request{ + .db = db, + .collection_name = token_collection, + .vectors = token_vectors, + .vectors_len = token_vector_upserts, + }), db, "user_delta.token_vectors")) return false; + if (testFailPhase("user_delta_after_vector_puts")) return false; + done = true; + out_node_keys.* = .{ .items = node_keys, .count = node_count }; + return true; +} + +/// Upserts the token metadata and file hashes of the delta, replaces the +/// project summary when the delta carries one, and drops file rows nothing +/// refers to any more. +fn userDeltaUpsertMetadataTx( + db: ?*Db, + workspace_id: Str, + workspace_key: i64, + delta: ?*const WorkspaceDelta, + metrics: *const DeltaMetrics, + file_keys: []const UserFileKey, + token_keys: []const UserTokenKey, +) bool { + if (metrics.token_vector_upserts != 0) { + const statement = prepareZova(db, "INSERT INTO cbm_token_vector_metadata_v1(token_key,workspace_key,token,idf) " ++ + "VALUES(?1,?2,?3,?4) ON CONFLICT(workspace_key,token) DO UPDATE SET " ++ + "idf=excluded.idf", "user_delta.token_upsert_prepare") orelse return false; + defer finalize(statement); + for (0..@intCast(metrics.token_vector_upserts)) |i| { + const row = c.cbm_zova_workspace_delta_token_vector_upsert_at(delta, @intCast(i)); + const token_key = userTokenKeyFind(token_keys, row.*.source.*.token); + if (token_key <= 0) return false; + if (!workspaceBindI64(db, statement, 1, token_key, "user_delta.token_key") or + !workspaceBindI64(db, statement, 2, workspace_key, "user_delta.token_ws") or + !workspaceBindText(db, statement, 3, row.*.source.*.token, "user_delta.token_value") or + !statusOk(c.zova_statement_bind_double(&c.zova_statement_bind_double_request{ + .statement = statement, + .index = 4, + .value = row.*.source.*.idf, + }), db, "user_delta.token_idf") or + !workspaceStepDone(db, statement, "user_delta.token_step") or + !userDatabaseResetStatement(statement, "user_delta.token_reset")) return false; + } + } + if (metrics.file_hash_upserts != 0) { + const statement = prepareZova(db, "INSERT INTO cbm_file_hashes_v1(file_key,content_hash,mtime_ns,size_bytes) " ++ + "VALUES(?1,?2,?3,?4) ON CONFLICT(file_key) DO UPDATE SET " ++ + "content_hash=excluded.content_hash,mtime_ns=excluded.mtime_ns,size_bytes=excluded.size_bytes", "user_delta.hash_upsert_prepare") orelse return false; + defer finalize(statement); + for (0..@intCast(metrics.file_hash_upserts)) |i| { + const row = c.cbm_zova_workspace_delta_file_hash_upsert_at(delta, @intCast(i)); + const file_key = userFileKeyFind(file_keys, row.*.file_path); + if (file_key <= 0) return false; + if (!workspaceBindI64(db, statement, 1, file_key, "user_delta.hash_file_key") or + !workspaceBindText(db, statement, 2, row.*.content_hash, "user_delta.hash_value") or + !workspaceBindI64(db, statement, 3, row.*.mtime_ns, "user_delta.hash_mtime") or + !workspaceBindI64(db, statement, 4, row.*.size_bytes, "user_delta.hash_size") or + !workspaceStepDone(db, statement, "user_delta.hash_step") or + !userDatabaseResetStatement(statement, "user_delta.hash_reset")) return false; + } + } + if (c.cbm_zova_workspace_delta_replaces_summary(delta) and + !userDatabaseWriteSummary(db, workspace_id, c.cbm_zova_workspace_delta_summary(delta))) return false; + const may_have_orphan_files = metrics.node_deletes != 0 or metrics.node_updates != 0 or metrics.file_hash_deletes != 0; + if (may_have_orphan_files) { + const statement = prepareZova(db, "DELETE FROM cbm_files_v1 WHERE workspace_key=?1 " ++ + "AND NOT EXISTS(SELECT 1 FROM cbm_nodes_v1 n " ++ + "WHERE n.workspace_key=cbm_files_v1.workspace_key " ++ + "AND n.file_key=cbm_files_v1.file_key) " ++ + "AND NOT EXISTS(SELECT 1 FROM cbm_file_hashes_v1 h WHERE h.file_key=cbm_files_v1.file_key)", "user_delta.files_cleanup_prepare") orelse return false; + defer finalize(statement); + if (!workspaceBindI64(db, statement, 1, workspace_key, "user_delta.files_cleanup_ws") or + !workspaceStepDone(db, statement, "user_delta.files_cleanup_step")) return false; + } + return !testFailPhase("user_delta_after_metadata_upserts"); +} + +/// Copies the timings of the publish model build into the result. +fn resultSetModelMetrics(out_result: *GenerationResult, model_metrics: *const c.cbm_zova_publish_model_metrics_t) void { + out_result.normalization_ms = model_metrics.normalization_ms; + out_result.model_nodes_ms = model_metrics.nodes_ms; + out_result.model_edges_ms = model_metrics.edges_ms; + out_result.model_edge_endpoint_ms = model_metrics.prepared_endpoint_ms; + out_result.model_edge_sort_ms = model_metrics.prepared_topology_sort_ms; + out_result.model_edge_group_ms = model_metrics.prepared_topology_group_ms; + out_result.model_edge_payload_ms = model_metrics.prepared_payload_ms; + out_result.model_edge_digest_ms = model_metrics.prepared_topology_digest_ms; + out_result.model_edge_default_payloads = model_metrics.prepared_single_default_payload_count; + out_result.model_edge_payload_scratch_edges = model_metrics.prepared_payload_scratch_edge_count; + out_result.model_hashes_ms = model_metrics.hashes_ms; + out_result.model_vectors_ms = model_metrics.vectors_ms; + out_result.model_digests_ms = model_metrics.digests_ms; +} + +/// Applies `delta` on top of the active generation and makes the result the +/// next ready generation. Runs inside the caller's transaction. +fn userDatabasePublishDeltaTx(db: ?*Db, model: ?*const PublishModel, delta: ?*const WorkspaceDelta, out_result: *GenerationResult) bool { + if (db == null or model == null or delta == null) return false; + const input = c.cbm_zova_publish_model_input(model); + const model_workspace_id = c.cbm_zova_publish_model_workspace_id(model); + const digests = c.cbm_zova_publish_model_digests(model); + if (input == null or model_workspace_id == null or digests == null) return false; + out_result.* = digests.*; + var metrics: DeltaMetrics = .{}; + c.cbm_zova_workspace_delta_metrics(delta, &metrics); + var model_metrics: c.cbm_zova_publish_model_metrics_t = .{}; + c.cbm_zova_publish_model_metrics(model, &model_metrics); + out_result.publication_mode = c.CBM_ZOVA_PUBLICATION_MODE_DELTA; + out_result.inserted_count = metrics.node_inserts + metrics.edge_inserts; + out_result.updated_count = metrics.node_updates + metrics.node_vector_upserts + + metrics.token_vector_upserts + metrics.file_hash_upserts + metrics.summary_replacements; + out_result.deleted_count = metrics.node_deletes + metrics.edge_deletes + + metrics.node_vector_deletes + metrics.token_vector_deletes + metrics.file_hash_deletes; + resultSetModelMetrics(out_result, &model_metrics); + out_result.diff_ms = metrics.diff_ms; + out_result.full_clear_count = 0; + out_result.unchanged_rewrite_count = 0; + out_result.nodes_inserted = metrics.node_inserts; + out_result.nodes_updated = metrics.node_updates; + out_result.nodes_deleted = metrics.node_deletes; + out_result.edges_inserted = metrics.edge_inserts; + out_result.edges_deleted = metrics.edge_deletes; + out_result.node_vectors_upserted = metrics.node_vector_upserts; + out_result.node_vectors_deleted = metrics.node_vector_deletes; + out_result.token_vectors_upserted = metrics.token_vector_upserts; + out_result.token_vectors_deleted = metrics.token_vector_deletes; + if (!registerSqlFunctions(db)) return false; + var workspace_id_buf: [ID_MAX]u8 = @splat(0); + if (!userWorkspaceIdTx(db, @ptrCast(input.*.root_path), &workspace_id_buf) or + c.strcmp(&workspace_id_buf, model_workspace_id) != 0) return false; + const workspace_id = bufZ(&workspace_id_buf); + const workspace_key = workspaceKeyForId(db, workspace_id) orelse return false; + const expected_generation = c.cbm_zova_workspace_delta_expected_generation(delta); + if (expected_generation <= 0) return false; + const active_generation = zovaQueryI64(db, "SELECT r.active_generation FROM cbm_workspace_registry r " ++ + "JOIN cbm_database_generation_v1 g ON g.workspace_key=r.workspace_key " ++ + "AND g.generation=r.active_generation AND g.state='ready' " ++ + "WHERE r.workspace_id=?1", workspace_id, "user_delta.active_generation") orelse return false; + if (active_generation != expected_generation) return false; + const current_max = zovaQueryI64(db, "SELECT COALESCE(MAX(generation),0) FROM cbm_database_generation_v1 " ++ + "WHERE workspace_key=(SELECT workspace_key FROM cbm_workspace_registry " ++ + "WHERE workspace_id=?1)", workspace_id, "user_delta.current_generation") orelse return false; + if (current_max <= 0 or current_max == std.math.maxInt(i64)) return false; + const generation = current_max + 1; + if (!userPublishInsertGenerationTx(db, workspace_id, generation)) return false; + if (testFailPhase("user_delta_after_generation")) return false; + + var node_collection_buf: [COLLECTION_NAME_CAP]u8 = undefined; + var token_collection_buf: [COLLECTION_NAME_CAP]u8 = undefined; + if (cbm_zova_workspace_node_vector_collection_name(workspace_id, @ptrCast(input.*.model_fingerprint), input.*.vector_dimensions, &node_collection_buf, node_collection_buf.len) != 0 or + cbm_zova_workspace_token_vector_collection_name(workspace_id, @ptrCast(input.*.model_fingerprint), input.*.vector_dimensions, &token_collection_buf, token_collection_buf.len) != 0) return false; + const node_collection = bufZ(&node_collection_buf); + const token_collection = bufZ(&token_collection_buf); + var file_keys: List(UserFileKey) = .{}; + defer c.free(file_keys.items); + var delta_node_keys: List(UserNodeKey) = .{}; + defer c.free(delta_node_keys.items); + var delta_token_keys: List(UserTokenKey) = .{}; + defer c.free(delta_token_keys.items); + var ok = true; + { + var node_index: NodeKeyIndex = .{}; + defer nodeKeyIndexFree(&node_index); + if (metrics.node_deletes != 0 or metrics.node_vector_deletes != 0 or metrics.node_vector_upserts != 0) { + ok = nodeKeyIndexBuild(db, workspace_id, &node_index); + } + ok = ok and userDeltaDeleteCanonicalTx(db, workspace_key, &node_index, delta, &metrics); + ok = ok and userDeltaDeleteNativeTx(db, workspace_id, &node_index, node_collection, token_collection, delta, &metrics); + ok = ok and userPublishProjectTx(db, workspace_id, input.*.root_path, input.*.project, input.*.indexed_at); + ok = ok and userDeltaFileKeysTx(db, workspace_key, delta, &metrics, &file_keys); + ok = ok and userDeltaTokenKeysTx(db, workspace_key, delta, &metrics, &delta_token_keys); + ok = ok and userDeltaPutNativeTx(db, workspace_id, &node_index, node_collection, token_collection, delta, &metrics, model, &delta_node_keys, delta_token_keys.slice()); + } + ok = ok and userDeltaUpsertNodesTx(db, workspace_key, delta, &metrics, file_keys.slice(), delta_node_keys.slice()); + ok = ok and !testFailPhase("user_delta_after_node_upserts"); + ok = ok and !testFailPhase("user_delta_after_edge_inserts"); + ok = ok and userDeltaUpsertMetadataTx(db, workspace_id, workspace_key, delta, &metrics, file_keys.slice(), delta_token_keys.slice()); + out_result.generation = generation; + + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + ok = ok and cbm_zova_workspace_graph_name(workspace_id, &graph_name, graph_name.len) == 0; + if (ok) { + var graph_info: c.zova_graph_info = .{}; + defer c.zova_graph_info_free(&graph_info); + ok = statusOk(c.zova_graph_info_get(&c.zova_graph_info_get_request{ + .db = db, + .name = &graph_name, + .out_info = &graph_info, + }), db, "user_delta.graph_readback") and + graph_info.node_count == out_result.graph_nodes and + graph_info.edge_count == out_result.graph_edges; + } + ok = ok and userPublishNativeReadbackCountsTx(db, out_result, node_collection, token_collection); + if (ok) { + user_publish_test_metrics.integrity_writes += 1; + ok = userPublishIntegrityTx(db, workspace_id, generation, out_result); + } + ok = ok and !testFailPhase("user_delta_after_integrity"); + ok = ok and userPublishWriteIndexStateTx(db, workspace_id, generation, input.*.model_fingerprint, input.*.vector_dimensions, input.*.indexed_at); + ok = ok and userPublishClearHealthTx(db, workspace_id); + ok = ok and userPublishGenerationStateTx(db, workspace_id, generation, true); + ok = ok and !testFailPhase("user_delta_before_commit"); + _ = interop.bprint(&out_result.workspace_id, "{s}", .{span(workspace_id)}); + if (ok) { + user_publish_test_metrics.delta_authoritative_rows_touched += + metrics.node_vector_deletes + metrics.token_vector_deletes + + metrics.topology_deletes + metrics.node_deletes + + metrics.node_deletes + metrics.node_updates + metrics.edge_deletes + + metrics.token_vector_deletes + metrics.file_hash_deletes + + metrics.node_inserts + metrics.node_updates + + metrics.node_inserts + metrics.node_updates + metrics.edge_inserts + + metrics.topology_inserts + metrics.node_inserts + metrics.node_updates + + metrics.node_vector_upserts + metrics.token_vector_upserts + + metrics.token_vector_upserts + metrics.file_hash_upserts + + metrics.summary_replacements; + } + return ok; +} + +// ── Publication: full, through a fresh build session ──────────── + +fn userExecI64Bound(db: ?*Db, sql: Str, bind_value: i64, phase: Str) bool { + if (db == null) return false; + const stmt = prepareZova(db, sql, phase) orelse return false; + defer finalize(stmt); + return workspaceBindI64(db, stmt, 1, bind_value, phase) and workspaceStepDone(db, stmt, phase); +} + +/// Drops the FTS insert trigger so a full publish can load cbm_nodes_v1 +/// without one trigger run per row. +fn userPublishFullNodesBeginTx(db: ?*Db) bool { + return statusOk(execSql(db, "DROP TRIGGER cbm_nodes_v1_fts_ai;"), db, "user_publish.nodes_bulk_begin"); +} + +/// Restores the FTS insert trigger; with `populate` it first fills the FTS +/// table from the node rows of the workspace in one statement. +fn userPublishFullNodesEndTx(db: ?*Db, workspace_key: i64, populate: bool) bool { + var ok = true; + if (populate) { + ok = userExecI64Bound(db, "INSERT INTO cbm_nodes_fts_v1(rowid,name,qualified_name,file_path,label) " ++ + "SELECT n.zova_node_key,cbm_camel_split(n.name),n.qualified_name,f.file_path,n.label " ++ + "FROM cbm_nodes_v1 n JOIN cbm_files_v1 f ON f.file_key=n.file_key " ++ + "WHERE n.workspace_key=?1 ORDER BY n.zova_node_key", workspace_key, "user_publish.fts_bulk_insert"); + if (ok) user_publish_test_metrics.full_fts_bulk_statements += 1; + } + const restored = statusOk(execSql(db, node_fts_insert_trigger_sql), db, "user_publish.nodes_bulk_restore"); + return ok and restored; +} + +const USER_FRESH_BATCH_ROWS: usize = 512; + +fn userFreshI64(value: i64) c.zova_fresh_value { + return .{ .value_type = c.ZOVA_FRESH_VALUE_INT64, .int64_value = value }; +} + +fn userFreshText(value: CStr) c.zova_fresh_value { + const text: CStr = if (value != null) value else ""; + return .{ .value_type = c.ZOVA_FRESH_VALUE_TEXT, .bytes = text, .bytes_len = c.strlen(text) }; +} + +fn userFreshLoadNodes( + db: ?*Db, + build: ?*c.zova_fresh_build, + model: ?*const PublishModel, + workspace_key: i64, + file_keys: []const UserFileKey, + node_keys: []const i64, +) bool { + const columns = [_]CStr{ + "zova_node_key", "workspace_key", "label", "name", "qualified_name", + "file_key", "start_line", "end_line", "properties", "source_ordinal", + }; + const count = countOf(c.cbm_zova_publish_model_node_count(model)); + const values = if (count != 0) interop.callocN(c.zova_fresh_value, USER_FRESH_BATCH_ROWS * columns.len) else null; + defer c.free(values); + if (count != 0 and values == null) return false; + user_publish_test_metrics.canonical_node_fts_passes += 1; + var offset: usize = 0; + while (offset < count) : (offset += USER_FRESH_BATCH_ROWS) { + const rows = @min(count - offset, USER_FRESH_BATCH_ROWS); + for (0..rows) |row_index| { + const index = offset + row_index; + const row = c.cbm_zova_publish_model_node_at(model, @intCast(index)); + if (row == null or row.*.source == null) return false; + const source = row.*.source; + const file_key = userFileKeyFind(file_keys, source.*.file_path); + if (index >= node_keys.len or node_keys[index] <= 0 or file_key <= 0 or row.*.ordinal != index) return false; + const out = values + row_index * columns.len; + out[0] = userFreshI64(node_keys[index]); + out[1] = userFreshI64(workspace_key); + out[2] = userFreshText(source.*.label); + out[3] = userFreshText(source.*.name); + out[4] = userFreshText(source.*.qualified_name); + out[5] = userFreshI64(file_key); + out[6] = userFreshI64(source.*.start_line); + out[7] = userFreshI64(source.*.end_line); + out[8] = userFreshText(if (source.*.properties != null) source.*.properties else "{}"); + out[9] = userFreshI64(@bitCast(row.*.source_ordinal)); + } + if (!statusOk(c.zova_fresh_build_table_rows(&c.zova_fresh_build_rows_request{ + .build = build, + .table_name = "cbm_nodes_v1", + .column_names = &columns, + .column_count = columns.len, + .values = values, + .row_count = rows, + }), db, "user_publish.fresh_nodes")) return false; + } + return true; +} + +fn userFreshLoadFts(db: ?*Db, build: ?*c.zova_fresh_build, model: ?*const PublishModel, node_keys: []const i64) bool { + const columns = [_]CStr{ "rowid", "name", "qualified_name", "file_path", "label" }; + const count = countOf(c.cbm_zova_publish_model_node_count(model)); + const values = if (count != 0) interop.callocN(c.zova_fresh_value, USER_FRESH_BATCH_ROWS * columns.len) else null; + defer c.free(values); + if (count != 0 and values == null) return false; + var offset: usize = 0; + while (offset < count) : (offset += USER_FRESH_BATCH_ROWS) { + const rows = @min(count - offset, USER_FRESH_BATCH_ROWS); + for (0..rows) |row_index| { + const index = offset + row_index; + const row = c.cbm_zova_publish_model_node_at(model, @intCast(index)); + if (row == null or row.*.source == null) return false; + const source = row.*.source; + const fts_name = c.cbm_zova_publish_model_fts_name_at(model, @intCast(index)); + if (fts_name == null or index >= node_keys.len or node_keys[index] <= 0) return false; + const out = values + row_index * columns.len; + out[0] = userFreshI64(node_keys[index]); + out[1] = userFreshText(fts_name); + out[2] = userFreshText(source.*.qualified_name); + out[3] = userFreshText(source.*.file_path); + out[4] = userFreshText(source.*.label); + } + if (!statusOk(c.zova_fresh_build_fts_rows(&c.zova_fresh_build_rows_request{ + .build = build, + .table_name = "cbm_nodes_fts_v1", + .column_names = &columns, + .column_count = columns.len, + .values = values, + .row_count = rows, + }), db, "user_publish.fresh_fts")) return false; + } + return true; +} + +fn userFreshLoadNodeVectors(db: ?*Db, build: ?*c.zova_fresh_build, model: ?*const PublishModel, collection: Str, node_keys: []const i64) bool { + const input = c.cbm_zova_publish_model_input(model); + const count = countOf(c.cbm_zova_publish_model_node_vector_count(model)); + const vectors = if (count != 0) interop.callocN(c.zova_vector_input, USER_FRESH_BATCH_ROWS) else null; + defer c.free(vectors); + const ids = if (count != 0) interop.callocN([32]u8, USER_FRESH_BATCH_ROWS) else null; + defer c.free(ids); + if (count != 0 and (vectors == null or ids == null)) return false; + var offset: usize = 0; + while (offset < count) : (offset += USER_FRESH_BATCH_ROWS) { + const rows = @min(count - offset, USER_FRESH_BATCH_ROWS); + for (0..rows) |row_index| { + const index = offset + row_index; + const row = c.cbm_zova_publish_model_node_vector_at(model, @intCast(index)); + if (row == null or row.*.node_ordinal >= node_keys.len) return false; + const key = node_keys[@intCast(row.*.node_ordinal)]; + if (row.*.ordinal != index or key <= 0 or !userPrivateKeyId(key, idAt(ids, row_index))) return false; + vectors[row_index] = i8VectorInput(idAt(ids, row_index), row.*.source.*.vector, input.*.vector_dimensions); + } + if (!statusOk(c.zova_fresh_build_vectors(&c.zova_fresh_build_vectors_request{ + .build = build, + .collection_name = collection, + .vectors = vectors, + .vectors_len = rows, + }), db, "user_publish.fresh_node_vectors")) return false; + } + return true; +} + +fn userFreshLoadTokenVectors(db: ?*Db, build: ?*c.zova_fresh_build, model: ?*const PublishModel, collection: Str, token_keys: []const UserTokenKey) bool { + const input = c.cbm_zova_publish_model_input(model); + const count = countOf(c.cbm_zova_publish_model_token_vector_count(model)); + const vectors = if (count != 0) interop.callocN(c.zova_vector_input, USER_FRESH_BATCH_ROWS) else null; + defer c.free(vectors); + const ids = if (count != 0) interop.callocN([32]u8, USER_FRESH_BATCH_ROWS) else null; + defer c.free(ids); + if (count != 0 and (vectors == null or ids == null)) return false; + if (count != token_keys.len) return false; + var offset: usize = 0; + while (offset < count) : (offset += USER_FRESH_BATCH_ROWS) { + const rows = @min(count - offset, USER_FRESH_BATCH_ROWS); + for (0..rows) |row_index| { + const index = offset + row_index; + const row = c.cbm_zova_publish_model_token_vector_at(model, @intCast(index)); + const key = token_keys[index].token_key; + if (row == null or row.*.source == null or row.*.ordinal != index or key <= 0 or + c.strcmp(token_keys[index].token, row.*.source.*.token) != 0 or + !userPrivateKeyId(key, idAt(ids, row_index))) return false; + vectors[row_index] = i8VectorInput(idAt(ids, row_index), row.*.source.*.vector, input.*.vector_dimensions); + } + if (!statusOk(c.zova_fresh_build_vectors(&c.zova_fresh_build_vectors_request{ + .build = build, + .collection_name = collection, + .vectors = vectors, + .vectors_len = rows, + }), db, "user_publish.fresh_token_vectors")) return false; + } + return true; +} + +/// Whether the database holds no graph yet, which a fresh build session +/// requires; NULL when the graphs cannot be listed. +fn userPublishCanUseFreshSession(db: ?*Db) ?bool { + if (db == null) return null; + var graphs: c.zova_graph_list = .{}; + defer c.zova_graph_list_free(&graphs); + if (!statusOk(c.zova_graphs_list(&c.zova_graph_list_request{ .db = db, .out_list = &graphs }), db, "user_publish.fresh_graph_list")) return null; + return graphs.len == 0; +} + +const UserFreshPublishProfile = struct { + graph: UserNativeGraphProfile = .{}, + validation_ms: f64 = 0, + table_ms: f64 = 0, + fts_ms: f64 = 0, + vector_ms: f64 = 0, + index_ms: f64 = 0, + commit_ms: f64 = 0, + build_ms: f64 = 0, +}; + +/// `unavailable`: this Zova cannot open a fresh build session here, nothing +/// was written, and the caller publishes the ordinary way. +const FreshOutcome = enum { published, failed, unavailable }; + +/// Loads the graph, the FTS rows, the node rows and both vector collections +/// of the model through one Zova fresh build session. +fn userPublishModelFreshSessionTx( + db: ?*Db, + model: ?*const PublishModel, + workspace_key: i64, + file_keys: []const UserFileKey, + token_keys: []const UserTokenKey, + node_collection: Str, + token_collection: Str, + out_nodes: ?*u64, + out_edges: ?*u64, + out_profile: *UserFreshPublishProfile, +) FreshOutcome { + if (db == null or model == null) return .failed; + out_profile.* = .{}; + const input = c.cbm_zova_publish_model_input(model); + const workspace_id = c.cbm_zova_publish_model_workspace_id(model); + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (input == null or workspace_id == null or + cbm_zova_workspace_graph_name(@ptrCast(workspace_id), &graph_name, graph_name.len) != 0) return .failed; + const node_count = countOf(c.cbm_zova_publish_model_node_count(model)); + const edge_count = countOf(c.cbm_zova_publish_model_topology_count(model)); + var mark: c.struct_timespec = undefined; + const published = blk: { + const nodes = callocList(c.zova_graph_fresh_node_input, node_count); + defer c.free(nodes); + const edges = callocList(c.zova_graph_fresh_edge_payload_input, edge_count); + defer c.free(edges); + const node_keys = callocList(i64, node_count); + defer c.free(node_keys); + const edge_keys = callocList(i64, edge_count); + defer c.free(edge_keys); + if ((node_count != 0 and (nodes == null or node_keys == null)) or + (edge_count != 0 and (edges == null or edge_keys == null))) return .failed; + mark = monotonicNow(); + var ok = true; + for (0..node_count) |i| { + const row = c.cbm_zova_publish_model_node_at(model, @intCast(i)); + if (row == null or row.*.source == null or row.*.ordinal != i) { + ok = false; + break; + } + nodes[i] = .{ + .node_id = row.*.stable_id, + .kind = row.*.source.*.label, + .target_type = c.ZOVA_GRAPH_TARGET_NONE, + }; + } + if (ok) { + for (0..edge_count) |i| { + const row = c.cbm_zova_publish_model_topology_at(model, @intCast(i)); + if (row == null or row.*.source == null or row.*.ordinal != i) { + ok = false; + break; + } + edges[i] = .{ + .from_node_ordinal = @intCast(row.*.source_ordinal), + .edge_type = row.*.source.*.type, + .to_node_ordinal = @intCast(row.*.target_ordinal), + .payload = row.*.payload, + .payload_len = row.*.payload_len, + }; + } + } + out_profile.graph.materialize_ms = userPublishProfilePhase("fresh.materialize", &mark); + ok = ok and collectionCreateI8(db, node_collection, input.*.vector_dimensions); + ok = ok and collectionCreateI8(db, token_collection, input.*.vector_dimensions); + ok = ok and userPublishFullNodesBeginTx(db); + + var build: ?*c.zova_fresh_build = null; + if (ok) { + const begin_status = c.zova_fresh_build_begin(&c.zova_fresh_build_begin_request{ .db = db, .out_build = &build }); + if (begin_status == c.ZOVA_INVALID_ARGUMENT) { + return if (userPublishFullNodesEndTx(db, workspace_key, false)) .unavailable else .failed; + } + ok = statusOk(begin_status, db, "user_publish.fresh_begin"); + } + out_profile.graph.reset_ms = userPublishProfilePhase("fresh.begin", &mark); + const fresh_started = mark; + if (ok) { + user_publish_test_metrics.native_graph_fresh_calls += 1; + user_publish_test_metrics.native_graph_prepared_calls += 1; + ok = statusOk(c.zova_fresh_build_graph(&c.zova_fresh_build_graph_request{ + .build = build, + .graph_name = &graph_name, + .nodes = nodes, + .nodes_len = node_count, + .edges = edges, + .edges_len = edge_count, + .out_node_keys = node_keys, + .out_node_keys_capacity = node_count, + .out_edge_keys = edge_keys, + .out_edge_keys_capacity = edge_count, + }), db, "user_publish.fresh_graph"); + } + const keys = many(node_keys, node_count); + ok = ok and !testFailPhase("user_after_graph_nodes"); + ok = ok and !testFailPhase("user_after_graph_edges"); + ok = ok and userFreshLoadFts(db, build, model, keys); + ok = ok and !testFailPhase("user_after_bulk_fts"); + ok = ok and userFreshLoadNodes(db, build, model, workspace_key, file_keys, keys); + ok = ok and !testFailPhase("user_after_nodes_before_bulk_finalize"); + ok = ok and !testFailPhase("user_after_edges_before_bulk_finalize"); + ok = ok and userFreshLoadNodeVectors(db, build, model, node_collection, keys); + ok = ok and !testFailPhase("user_after_node_vectors"); + ok = ok and userFreshLoadTokenVectors(db, build, model, token_collection, token_keys); + ok = ok and !testFailPhase("user_after_token_vectors"); + + var profile: c.zova_fresh_build_profile = .{}; + ok = ok and statusOk(c.zova_fresh_build_finish(&c.zova_fresh_build_finish_request{ + .build = build, + .out_node_keys = node_keys, + .out_node_keys_capacity = node_count, + .out_edge_keys = edge_keys, + .out_edge_keys_capacity = edge_count, + .out_profile = &profile, + }), db, "user_publish.fresh_finish"); + if (!ok and build != null) _ = c.zova_fresh_build_abort(build); + if (build != null) c.zova_fresh_build_destroy(build); + ok = ok and userPublishFullNodesEndTx(db, workspace_key, false); + if (ok) { + for (keys) |key| { + if (key <= 0) ok = false; + } + for (many(edge_keys, edge_count)) |key| { + if (key <= 0) ok = false; + } + } + const fresh_completed = monotonicNow(); + out_profile.build_ms = userPublishElapsedMs(&fresh_started, &fresh_completed); + out_profile.validation_ms = profile.validation_ms; + out_profile.graph.nodes_ms = profile.graph_node_load_ms; + out_profile.graph.edges_ms = profile.graph_edge_load_ms; + out_profile.graph.validate_ms = profile.graph_validation_ms; + out_profile.graph.key_generation_ms = profile.graph_key_generation_ms; + out_profile.table_ms = profile.table_load_ms; + out_profile.fts_ms = profile.fts_load_ms; + out_profile.vector_ms = profile.vector_load_ms; + out_profile.index_ms = profile.index_build_ms; + out_profile.commit_ms = profile.commit_ms; + if (ok) { + if (out_nodes) |out| out.* = node_count; + if (out_edges) |out| out.* = edge_count; + user_publish_test_metrics.full_fts_trigger_rows_avoided += node_count; + } + mark = monotonicNow(); + break :blk ok; + }; + out_profile.graph.cleanup_ms = userPublishProfilePhase("fresh.cleanup", &mark); + return if (published) .published else .failed; +} + +// ── Publication: full ─────────────────────────────────────────── + +fn isEmpty(text: CStr) bool { + return text == null or text[0] == 0; +} + +/// Replaces everything the workspace of `model` holds and makes the result +/// the next ready generation (`requested_generation` when it is positive). +/// Runs inside the caller's transaction. +fn userDatabasePublishModelTx(db: ?*Db, model: ?*const PublishModel, requested_generation: i64, maybe_out_result: ?*GenerationResult) bool { + const input = c.cbm_zova_publish_model_input(model); + const out_result = maybe_out_result orelse return false; + if (db == null or input == null or isEmpty(input.*.root_path) or isEmpty(input.*.project) or + isEmpty(input.*.indexed_at) or isEmpty(input.*.model_fingerprint) or input.*.vector_dimensions <= 0 or + input.*.node_count < 0 or input.*.edge_count < 0 or input.*.node_vector_count < 0 or + input.*.token_vector_count < 0 or input.*.file_hash_count < 0 or + (input.*.node_count > 0 and input.*.nodes == null) or (input.*.edge_count > 0 and input.*.edges == null) or + (input.*.node_vector_count > 0 and input.*.node_vectors == null) or + (input.*.token_vector_count > 0 and input.*.token_vectors == null) or + (input.*.file_hash_count > 0 and input.*.file_hashes == null)) + { + return false; + } + var profile_mark = monotonicNow(); + const model_result = c.cbm_zova_publish_model_digests(model); + const model_workspace_id = c.cbm_zova_publish_model_workspace_id(model); + if (model_result == null or model_workspace_id == null) return false; + out_result.* = model_result.*; + var model_metrics: c.cbm_zova_publish_model_metrics_t = .{}; + c.cbm_zova_publish_model_metrics(model, &model_metrics); + out_result.publication_mode = c.CBM_ZOVA_PUBLICATION_MODE_FULL; + out_result.inserted_count = out_result.metadata_nodes + out_result.metadata_edges + + out_result.node_vectors + out_result.token_vectors + countOf(input.*.file_hash_count); + resultSetModelMetrics(out_result, &model_metrics); + out_result.full_clear_count = 1; + out_result.nodes_inserted = out_result.metadata_nodes; + out_result.edges_inserted = out_result.metadata_edges; + out_result.node_vectors_upserted = out_result.node_vectors; + out_result.token_vectors_upserted = out_result.token_vectors; + if (!registerSqlFunctions(db)) return false; + if (!statusOk(execSql(db, "PRAGMA cache_size=-65536"), db, "user_publish.cache_size")) return false; + if (!workspaceNameComponentValid(@ptrCast(input.*.model_fingerprint))) return false; + _ = userPublishProfilePhase("normalize", &profile_mark); + + var workspace_id_buf: [ID_MAX]u8 = @splat(0); + if (!userWorkspaceIdTx(db, @ptrCast(input.*.root_path), &workspace_id_buf) or + c.strcmp(&workspace_id_buf, model_workspace_id) != 0) return false; + const workspace_id = bufZ(&workspace_id_buf); + const workspace_key = workspaceKeyForId(db, workspace_id) orelse return false; + const current_max = zovaQueryI64(db, "SELECT COALESCE(MAX(generation),0) FROM cbm_database_generation_v1 " ++ + "WHERE workspace_key=(SELECT workspace_key FROM cbm_workspace_registry " ++ + "WHERE workspace_id=?1)", workspace_id, "user_publish.current_generation") orelse return false; + if (current_max < 0 or + (requested_generation > 0 and requested_generation <= current_max) or + (requested_generation == 0 and current_max == std.math.maxInt(i64))) return false; + const generation = if (requested_generation > 0) requested_generation else current_max + 1; + if (generation <= 0 or !userPublishInsertGenerationTx(db, workspace_id, generation)) return false; + + user_publish_test_metrics.full_clear_count += 1; + var ok = userDatabaseClearWorkspace(db, workspace_id); + var file_keys: List(UserFileKey) = .{}; + defer c.free(file_keys.items); + var node_keys: [*c]UserNodeKey = null; + defer c.free(node_keys); + var token_keys: List(UserTokenKey) = .{}; + defer c.free(token_keys.items); + var topology_keys: [*c]UserTopologyKey = null; + defer c.free(topology_keys); + out_result.clear_ms = userPublishProfilePhase("clear", &profile_mark); + ok = ok and userPublishProjectTx(db, workspace_id, input.*.root_path, input.*.project, input.*.indexed_at); + ok = ok and userPublishFileKeysTx(db, model, workspace_key, &file_keys); + out_result.canonical_files_ms = userPublishProfilePhase("canonical_files", &profile_mark); + + var node_collection_buf: [COLLECTION_NAME_CAP]u8 = @splat(0); + var token_collection_buf: [COLLECTION_NAME_CAP]u8 = @splat(0); + ok = ok and cbm_zova_workspace_node_vector_collection_name(workspace_id, @ptrCast(input.*.model_fingerprint), input.*.vector_dimensions, &node_collection_buf, node_collection_buf.len) == 0 and + cbm_zova_workspace_token_vector_collection_name(workspace_id, @ptrCast(input.*.model_fingerprint), input.*.vector_dimensions, &token_collection_buf, token_collection_buf.len) == 0; + const node_collection = bufZ(&node_collection_buf); + const token_collection = bufZ(&token_collection_buf); + + var graph_nodes: u64 = 0; + var graph_edges: u64 = 0; + var use_fresh_session = false; + if (ok) { + if (userPublishCanUseFreshSession(db)) |can_use| use_fresh_session = can_use else ok = false; + } + var hashes_published = false; + var token_metadata_published = false; + if (ok and use_fresh_session) { + ok = userPublishModelHashesTx(db, model, file_keys.slice()); + ok = ok and userDatabaseWriteSummary(db, workspace_id, &input.*.project_summary); + hashes_published = ok; + out_result.canonical_hashes_ms = userPublishProfilePhase("canonical_hashes", &profile_mark); + ok = ok and userPublishModelTokenMetadataTx(db, model, workspace_key, &token_keys); + token_metadata_published = ok; + out_result.token_metadata_ms = userPublishProfilePhase("token_metadata", &profile_mark); + ok = ok and !testFailPhase("user_after_metadata"); + + var fresh_profile: UserFreshPublishProfile = .{}; + if (ok) { + switch (userPublishModelFreshSessionTx(db, model, workspace_key, file_keys.slice(), token_keys.slice(), node_collection, token_collection, &graph_nodes, &graph_edges, &fresh_profile)) { + .published => {}, + .failed => ok = false, + .unavailable => { + use_fresh_session = false; + profile_mark = monotonicNow(); + }, + } + } + if (use_fresh_session) { + const graph = fresh_profile.graph; + out_result.native_graph_materialize_ms = graph.materialize_ms; + out_result.native_graph_reset_ms = graph.reset_ms; + out_result.native_graph_nodes_ms = graph.nodes_ms; + out_result.native_graph_edges_ms = graph.edges_ms; + out_result.native_graph_validate_ms = graph.validate_ms; + out_result.native_graph_key_generation_ms = graph.key_generation_ms; + out_result.native_graph_cleanup_ms = graph.cleanup_ms; + out_result.native_graph_ms = graph.materialize_ms + graph.reset_ms + graph.nodes_ms + + graph.edges_ms + graph.validate_ms + graph.key_generation_ms + graph.cleanup_ms; + out_result.canonical_nodes_ms = fresh_profile.table_ms; + out_result.fts_ms = fresh_profile.fts_ms; + out_result.canonical_edges_ms = 0.0; + out_result.native_vectors_ms = fresh_profile.vector_ms; + out_result.fresh_validation_ms = fresh_profile.validation_ms; + out_result.fresh_index_ms = fresh_profile.index_ms; + out_result.fresh_commit_ms = fresh_profile.commit_ms; + out_result.fresh_build_ms = fresh_profile.build_ms; + profile_mark = monotonicNow(); + ok = ok and !testFailPhase("user_after_fts"); + } + } + if (ok and !use_fresh_session) { + var graph_profile: UserNativeGraphProfile = .{}; + ok = userPublishModelGraphTx(db, model, &node_keys, &topology_keys, &graph_nodes, &graph_edges, &graph_profile); + out_result.native_graph_materialize_ms = graph_profile.materialize_ms; + out_result.native_graph_reset_ms = graph_profile.reset_ms; + out_result.native_graph_nodes_ms = graph_profile.nodes_ms; + out_result.native_graph_edges_ms = graph_profile.edges_ms; + out_result.native_graph_validate_ms = graph_profile.validate_ms; + out_result.native_graph_key_generation_ms = graph_profile.key_generation_ms; + out_result.native_graph_cleanup_ms = graph_profile.cleanup_ms; + out_result.native_graph_ms = userPublishProfilePhase("native_graph", &profile_mark); + + const model_node_keys = many(node_keys, c.cbm_zova_publish_model_node_count(model)); + var nodes_bulk_active = false; + if (ok) { + ok = userPublishFullNodesBeginTx(db); + nodes_bulk_active = ok; + } + ok = ok and userPublishModelNodesTx(db, model, workspace_key, file_keys.slice(), model_node_keys); + out_result.canonical_nodes_ms = userPublishProfilePhase("canonical_nodes", &profile_mark); + const populate_fts = ok and !testFailPhase("user_after_nodes_before_bulk_finalize"); + ok = ok and populate_fts; + if (nodes_bulk_active) { + const ended = userPublishFullNodesEndTx(db, workspace_key, populate_fts); + ok = ok and ended; + if (populate_fts and ended) { + user_publish_test_metrics.full_fts_trigger_rows_avoided += countOf(input.*.node_count); + } + } + out_result.fts_ms = userPublishProfilePhase("fts", &profile_mark); + ok = ok and !testFailPhase("user_after_bulk_fts"); + ok = ok and !testFailPhase("user_after_edges_before_bulk_finalize"); + out_result.canonical_edges_ms = userPublishProfilePhase("canonical_edges", &profile_mark); + if (!hashes_published) { + ok = ok and userPublishModelHashesTx(db, model, file_keys.slice()); + ok = ok and userDatabaseWriteSummary(db, workspace_id, &input.*.project_summary); + out_result.canonical_hashes_ms = userPublishProfilePhase("canonical_hashes", &profile_mark); + } + if (!token_metadata_published) { + ok = ok and userPublishModelTokenMetadataTx(db, model, workspace_key, &token_keys); + out_result.token_metadata_ms = userPublishProfilePhase("token_metadata", &profile_mark); + ok = ok and !testFailPhase("user_after_metadata"); + } + ok = ok and !testFailPhase("user_after_fts"); + ok = ok and userPublishModelVectorsTx(db, model, node_collection, token_collection, model_node_keys, token_keys.slice()); + out_result.native_vectors_ms = userPublishProfilePhase("native_vectors", &profile_mark); + } + + out_result.generation = generation; + ok = ok and graph_nodes == out_result.graph_nodes and graph_edges == out_result.graph_edges; + _ = userPublishProfilePhase("digests", &profile_mark); + ok = ok and userPublishNativeReadbackCountsTx(db, out_result, node_collection, token_collection); + out_result.readback_ms = userPublishProfilePhase("readback", &profile_mark); + if (ok) { + user_publish_test_metrics.integrity_writes += 1; + ok = userPublishIntegrityTx(db, workspace_id, generation, out_result); + } + ok = ok and !testFailPhase("user_after_integrity"); + ok = ok and userPublishWriteIndexStateTx(db, workspace_id, generation, input.*.model_fingerprint, input.*.vector_dimensions, input.*.indexed_at); + ok = ok and userPublishClearHealthTx(db, workspace_id); + ok = ok and userPublishGenerationStateTx(db, workspace_id, generation, true); + ok = ok and !testFailPhase("user_before_commit"); + out_result.finalize_ms = userPublishProfilePhase("finalize", &profile_mark); + _ = interop.bprint(&out_result.workspace_id, "{s}", .{span(workspace_id)}); + return ok; +} + +/// The publish model of `input`, which the caller frees; NULL when the +/// workspace id or the model cannot be built. +fn publishModelBuild(maybe_input: ?*const GenerationInput) ?*PublishModel { + const input = maybe_input orelse return null; + var workspace_id: [ID_MAX]u8 = undefined; + var model: ?*PublishModel = null; + if (cbm_zova_workspace_id_for_root(@ptrCast(input.root_path), &workspace_id, workspace_id.len) != 0 or + c.cbm_zova_publish_model_build(&workspace_id, input, &model) != 0) return null; + return model; +} + +fn userDatabasePublishWorkspaceTx(db: ?*Db, input: ?*const GenerationInput, out_result: ?*GenerationResult) bool { + const model = publishModelBuild(input) orelse return false; + defer c.cbm_zova_publish_model_free(model); + return userDatabasePublishModelTx(db, model, 0, out_result); +} + +/// st_size of `path` through stat(); NULL when stat fails. +fn fileBytes(path: Str) ?u64 { + var value: c.struct_stat = undefined; + return if (c.stat(path, &value) == 0) @bitCast(@as(i64, value.st_size)) else null; +} + +/// What one publication transaction writes: the whole workspace, or a delta +/// on top of its active generation. +const Publication = union(enum) { + full: struct { model: *const PublishModel, requested_generation: i64 }, + delta: struct { after: *const PublishModel, delta: *const WorkspaceDelta }, +}; + +/// Takes the writer guard, opens (or creates) the database at `zova_path`, +/// runs `publication` in one transaction and records the timing and the file +/// sizes of every stage in `out_result`. +fn userDatabasePublish(zova_path: Str, publication: Publication, comptime wal_phase: Str, out_result: *GenerationResult) bool { + out_result.* = std.mem.zeroes(GenerationResult); + var phase_started = monotonicNow(); + var writer_guard: c.cbm_zova_writer_guard_t = .{}; + if (c.cbm_zova_writer_guard_acquire(zova_path, &writer_guard) != 0) return false; + defer c.cbm_zova_writer_guard_release(&writer_guard); + var phase_finished = monotonicNow(); + const writer_guard_ms = userPublishElapsedMs(&phase_started, &phase_finished); + phase_started = phase_finished; + var zdb: ZovaDb = .{}; + if (!openOrCreateZova(zova_path, &zdb)) return false; + user_publish_test_metrics.database_open_count += 1; + phase_finished = monotonicNow(); + const database_open_ms = userPublishElapsedMs(&phase_started, &phase_finished); + phase_started = phase_finished; + if (!userDatabaseEnsureSchema(zdb.db)) { + closeZova(&zdb); + user_publish_test_metrics.database_close_count += 1; + return false; + } + phase_finished = monotonicNow(); + const database_init_ms = userPublishElapsedMs(&phase_started, &phase_finished); + const started = monotonicNow(); + var wal_path_buf: [ZV_PATH_MAX]u8 = undefined; + const wal_path: ?Str = if (interop.bprint(&wal_path_buf, "{s}-wal", .{span(zova_path)}) < wal_path_buf.len) bufZ(&wal_path_buf) else null; + const database_bytes_before = fileBytes(zova_path) orelse 0; + const wal_bytes_before = if (wal_path) |path| fileBytes(path) orelse 0 else 0; + phase_started = monotonicNow(); + var ok = statusOk(execSql(zdb.db, "PRAGMA journal_mode=WAL;" ++ + "PRAGMA synchronous=NORMAL;" ++ + "PRAGMA cache_size=-65536;"), zdb.db, wal_phase); + if (ok) { + user_publish_test_metrics.transaction_count += 1; + ok = workspaceRegistryBegin(zdb.db); + } + phase_finished = monotonicNow(); + const transaction_begin_ms = userPublishElapsedMs(&phase_started, &phase_finished); + phase_started = phase_finished; + if (ok) { + switch (publication) { + .full => |full| ok = userDatabasePublishModelTx(zdb.db, full.model, full.requested_generation, out_result), + .delta => |incremental| { + user_publish_delta_active = true; + ok = userDatabasePublishDeltaTx(zdb.db, incremental.after, incremental.delta, out_result); + user_publish_delta_active = false; + }, + } + } + phase_finished = monotonicNow(); + const transaction_body_ms = userPublishElapsedMs(&phase_started, &phase_finished); + phase_started = phase_finished; + if (ok) { + ok = workspaceRegistryCommit(zdb.db); + } else { + workspaceRegistryRollback(zdb.db); + } + phase_finished = monotonicNow(); + const transaction_commit_ms = userPublishElapsedMs(&phase_started, &phase_finished); + phase_started = phase_finished; + closeZova(&zdb); + user_publish_test_metrics.database_close_count += 1; + phase_finished = monotonicNow(); + const database_close_ms = userPublishElapsedMs(&phase_started, &phase_finished); + const finished = monotonicNow(); + out_result.publish_ms = userPublishElapsedMs(&started, &finished); + out_result.writer_guard_ms = writer_guard_ms; + out_result.database_init_ms = database_init_ms; + out_result.database_open_ms = database_open_ms; + out_result.transaction_begin_ms = transaction_begin_ms; + out_result.transaction_body_ms = transaction_body_ms; + out_result.transaction_commit_ms = transaction_commit_ms; + out_result.database_close_ms = database_close_ms; + out_result.database_bytes = fileBytes(zova_path) orelse database_bytes_before; + out_result.wal_bytes_before = wal_bytes_before; + if (wal_path) |path| { + if (fileBytes(path)) |bytes| out_result.wal_bytes_after = bytes; + } + return ok; +} + +fn userDatabasePublishModelAtGeneration(zova_path: ?Str, maybe_model: ?*const PublishModel, requested_generation: i64, maybe_out_result: ?*GenerationResult) c_int { + const path = nonEmpty(zova_path) orelse return -1; + const model = maybe_model orelse return -1; + const out_result = maybe_out_result orelse return -1; + if (requested_generation < 0) return -1; + const publication: Publication = .{ .full = .{ .model = model, .requested_generation = requested_generation } }; + return if (userDatabasePublish(path, publication, "user_publish.wal", out_result)) 0 else -1; +} + +fn userDatabasePublishWorkspaceAtGeneration(zova_path: ?Str, input: ?*const GenerationInput, requested_generation: i64, out_result: ?*GenerationResult) c_int { + const model = publishModelBuild(input) orelse return -1; + defer c.cbm_zova_publish_model_free(model); + return userDatabasePublishModelAtGeneration(zova_path, model, requested_generation, out_result); +} + +/// The no-Zova result of every publish entry point: a zeroed result and -1. +fn publishUnavailable(out_result: ?*GenerationResult) c_int { + if (out_result) |result| result.* = std.mem.zeroes(GenerationResult); + return -1; +} + +pub export fn cbm_zova_user_database_publish_model(zova_path: ?Str, model: ?*const PublishModel, out_result: ?*GenerationResult) c_int { + if (!with_zova) return publishUnavailable(out_result); + return userDatabasePublishModelAtGeneration(zova_path, model, 0, out_result); +} + +pub export fn cbm_zova_user_database_publish_prepared_view(zova_path: ?Str, view: ?*const PreparedView, out_result: ?*GenerationResult) c_int { + if (!with_zova) return publishUnavailable(out_result); + return userDatabasePublishModelAtGeneration(zova_path, view, 0, out_result); +} + +pub export fn cbm_zova_user_database_publish_delta( + zova_path: ?Str, + maybe_after: ?*const PublishModel, + maybe_delta: ?*const WorkspaceDelta, + maybe_out_result: ?*GenerationResult, +) c_int { + if (!with_zova) return publishUnavailable(maybe_out_result); + const path = nonEmpty(zova_path) orelse return -1; + const after = maybe_after orelse return -1; + const delta = maybe_delta orelse return -1; + const out_result = maybe_out_result orelse return -1; + const publication: Publication = .{ .delta = .{ .after = after, .delta = delta } }; + return if (userDatabasePublish(path, publication, "user_delta.wal", out_result)) 0 else -1; +} + +pub export fn cbm_zova_user_database_publish_workspace(zova_path: ?Str, input: ?*const GenerationInput, out_result: ?*GenerationResult) c_int { + if (!with_zova) return publishUnavailable(out_result); + return userDatabasePublishWorkspaceAtGeneration(zova_path, input, 0, out_result); +} + +pub export fn cbm_zova_user_database_publish_workspace_at_generation( + zova_path: ?Str, + input: ?*const GenerationInput, + requested_generation: i64, + out_result: ?*GenerationResult, +) c_int { + if (!with_zova) return publishUnavailable(out_result); + if (requested_generation <= 0) return -1; + return userDatabasePublishWorkspaceAtGeneration(zova_path, input, requested_generation, out_result); +} + +// ── Workspace deletion ────────────────────────────────────────── + +/// Reads the embedding model and the vector size the workspace was indexed +/// with; `model` stays empty when it has no index state row. A statement +/// that cannot be prepared or bound counts as no row. +fn userDeleteReadIndexState(db: ?*Db, workspace_key: i64, model: *[256]u8, dimensions: *c_int) bool { + const state = prepareZova(db, "SELECT model_fingerprint,vector_dimensions FROM cbm_workspace_index_state_v1 " ++ + "WHERE workspace_key=?1", "user_delete.state_prepare") orelse return true; + defer finalize(state); + if (!workspaceBindI64(db, state, 1, workspace_key, "user_delete.state_bind")) return true; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (stepStatement(state, &step) != c.ZOVA_OK) return false; + if (step != c.ZOVA_STEP_ROW) return true; + var text: c.zova_text = .{}; + defer c.zova_text_free(&text); + if (c.zova_statement_column_text(&c.zova_statement_column_text_request{ + .statement = state, + .index = 0, + .out_text = &text, + }) != c.ZOVA_OK or text.data == null or text.len >= model.len) return false; + @memcpy(model[0..text.len], text.data[0..text.len]); + model[text.len] = 0; + var value: i64 = 0; + if (columnI64(state, 1, &value) != c.ZOVA_OK) return false; + dimensions.* = @truncate(value); + return true; +} + +/// Removes the graph, the vector collections and every row of the +/// workspace. Does nothing for a workspace the registry does not hold. +fn userDeleteWorkspaceTx(db: ?*Db, workspace_id: Str) bool { + const present = zovaQueryI64(db, "SELECT count(*) FROM cbm_workspace_registry WHERE workspace_id=?1", workspace_id, "user_delete.exists") orelse return false; + if (present == 0) return true; + const workspace_key = workspaceKeyForId(db, workspace_id) orelse return false; + + var model: [256]u8 = @splat(0); + var dimensions: c_int = 0; + if (!userDeleteReadIndexState(db, workspace_key, &model, &dimensions)) return false; + + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (cbm_zova_workspace_graph_name(workspace_id, &graph_name, graph_name.len) != 0) return false; + if (!graphDeleteNamedIfExists(db, bufZ(&graph_name))) return false; + if (model[0] != 0 and dimensions > 0) { + var nodes: [COLLECTION_NAME_CAP]u8 = undefined; + var tokens: [COLLECTION_NAME_CAP]u8 = undefined; + if (cbm_zova_workspace_node_vector_collection_name(workspace_id, bufZ(&model), dimensions, &nodes, nodes.len) != 0 or + cbm_zova_workspace_token_vector_collection_name(workspace_id, bufZ(&model), dimensions, &tokens, tokens.len) != 0 or + !collectionDeleteIfExists(db, bufZ(&nodes)) or + !collectionDeleteIfExists(db, bufZ(&tokens))) return false; + } + if (!userDatabaseClearWorkspace(db, workspace_id)) return false; + inline for (.{ + "cbm_workspace_health_v1", "cbm_workspace_index_state_v1", + "cbm_generation_integrity_v2", "cbm_database_generation_v1", + }) |table| { + const stmt = prepareZova(db, "DELETE FROM " ++ table ++ " WHERE workspace_key=?1", "user_delete.prepare") orelse return false; + defer finalize(stmt); + if (!workspaceBindI64(db, stmt, 1, workspace_key, "user_delete.bind") or + !workspaceStepDone(db, stmt, "user_delete.step")) return false; + } + const stmt = prepareZova(db, "DELETE FROM cbm_workspace_registry WHERE workspace_id=?1", "user_delete.registry_prepare") orelse return false; + defer finalize(stmt); + return workspaceBindText(db, stmt, 1, workspace_id, "user_delete.registry_bind") and + workspaceStepDone(db, stmt, "user_delete.registry_step"); +} + +pub export fn cbm_zova_user_database_delete_workspace(zova_path: ?Str, workspace_id: ?Str) c_int { + if (!with_zova) return -1; + const path = nonEmpty(zova_path) orelse return -1; + if (cbm_zova_workspace_id_validate(workspace_id) != 0) return -1; + var guard: c.cbm_zova_writer_guard_t = .{}; + if (c.cbm_zova_writer_guard_acquire(path, &guard) != 0) return -1; + defer c.cbm_zova_writer_guard_release(&guard); + var zdb: ZovaDb = .{}; + defer closeZova(&zdb); + if (!openZova(path, false, &zdb) or !workspaceRegistryBegin(zdb.db)) return -1; + if (!userDeleteWorkspaceTx(zdb.db, workspace_id.?)) { + workspaceRegistryRollback(zdb.db); + return -1; + } + return if (workspaceRegistryCommit(zdb.db)) 0 else -1; +} + +pub export fn cbm_zova_delete_workspace_graph(zova_path: ?Str, workspace_id: ?Str) c_int { + if (!with_zova) return -1; + const path = zova_path orelse return -1; + if (!workspaceNameComponentValid(workspace_id)) return -1; + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (cbm_zova_workspace_graph_name(workspace_id, &graph_name, graph_name.len) != 0) return -1; + var zdb: ZovaDb = .{}; + defer closeZova(&zdb); + if (!openZova(path, false, &zdb) or !workspaceRegistryBegin(zdb.db)) return -1; + if (!graphDeleteNamedIfExists(zdb.db, bufZ(&graph_name))) { + workspaceRegistryRollback(zdb.db); + return -1; + } + return if (workspaceRegistryCommit(zdb.db)) 0 else -1; +} + +// ── Vector session ────────────────────────────────────────────── + +/// A malloc'd copy of text column `index`; NULL when it cannot be read. +fn columnTextOwned(db: ?*Db, stmt: ?*Stmt, index: c_int) [*c]u8 { + var text: c.zova_text = .{}; + defer c.zova_text_free(&text); + if (!statusOk(c.zova_statement_column_text(&c.zova_statement_column_text_request{ + .statement = stmt, + .index = index, + .out_text = &text, + }), db, "column.text")) return null; + return if (text.data != null) zvStrndup(text.data, text.len) else zvStrndup("", 0); +} + +/// Opens `zova_path` read-only as a vector or a graph session and reads the +/// sidecar generation, when the database has one. +fn sessionOpen(comptime Session: type, zova_path: ?Str) ?*Session { + const path = zova_path orelse return null; + const session = interop.callocN(Session, 1); + if (session == null or !openZova(path, true, &session.*.zdb)) { + c.free(session); + return null; + } + if (sidecarGenerationReadDb(session.*.zdb.db)) |generation| { + session.*.generation = generation; + session.*.generation_valid = true; + } + return session; +} + +pub export fn cbm_zova_vector_session_open(zova_path: ?Str) ?*VectorSession { + if (!with_zova) return null; + return sessionOpen(VectorSession, zova_path); +} + +pub export fn cbm_zova_vector_session_search_collection_i8( + session: ?*VectorSession, + collection: ?Str, + query_values: [*c]const i8, + query_count: c_int, + vector_dim: c_int, + prefilter_limit: c_int, + limit: c_int, + out: ?*[*c]VectorHit, + out_count: ?*c_int, +) c_int { + if (!with_zova) { + if (out) |items| items.* = null; + if (out_count) |count| count.* = 0; + return -1; + } + const vector_session = session orelse return -1; + const collection_name = nonEmpty(collection) orelse return -1; + const out_items = out orelse return -1; + const out_items_count = out_count orelse return -1; + if (query_values == null or query_count <= 0 or vector_dim <= 0 or prefilter_limit <= 0 or limit <= 0) return -1; + out_items.* = null; + out_items_count.* = 0; + const db = vector_session.zdb.db; + var results: c.zova_vector_search_results = .{}; + defer c.zova_vector_search_results_free(&results); + const searched = if (query_count == 1) + statusOk(c.zova_vector_search(&c.zova_vector_search_request{ + .db = db, + .collection_name = collection_name, + .query = .{ + .element_type = c.ZOVA_VECTOR_ELEMENT_TYPE_I8, + .i8_values = query_values, + .values_len = sizeOfInt(vector_dim), + }, + .limit = sizeOfInt(limit), + .out_results = &results, + }), db, "workspace_vector.search_i8") + else + statusOk(c.zova_vector_search_multi_i8(&c.zova_vector_search_multi_i8_request{ + .db = db, + .collection_name = collection_name, + .query_values = query_values, + .query_values_len = sizeOfInt(query_count) * sizeOfInt(vector_dim), + .query_count = sizeOfInt(query_count), + .dimensions = sizeOfInt(vector_dim), + .candidate_ids = null, + .candidate_count = 0, + .mode = c.ZOVA_VECTOR_MULTI_I8_SEARCH_CBM_PREFILTER_MIN_COSINE, + .aggregation = c.ZOVA_VECTOR_MULTI_I8_AGGREGATION_MIN_COSINE, + .prefilter_query_index = 0, + .prefilter_limit = sizeOfInt(prefilter_limit), + .limit = sizeOfInt(limit), + .out_results = &results, + }), db, "workspace_vector.search_multi_i8"); + if (!searched) return -1; + const items = callocList(VectorHit, results.len); + if (results.len != 0 and items == null) return -1; + for (many(results.items, results.len), 0..) |*result, i| { + items[i].vector_id = zvStrndup(result.id, result.id_len); + if (items[i].vector_id == null) { + cbm_zova_vector_hits_free(items, @intCast(i)); + return -1; + } + items[i].score = 1.0 - result.distance; + } + out_items.* = items; + out_items_count.* = @intCast(results.len); + return 0; +} + +pub export fn cbm_zova_vector_session_get_workspace_token_i8( + session: ?*VectorSession, + workspace_id: ?Str, + model_fingerprint: ?Str, + vector_dim: c_int, + token: ?Str, + out_values: [*c]i8, + out_len: usize, + out_found: ?*bool, +) c_int { + if (!with_zova) { + if (out_found) |found| found.* = false; + return -1; + } + const vector_session = session orelse return -1; + const token_text = nonEmpty(token) orelse return -1; + const found = out_found orelse return -1; + if (!workspaceNameComponentValid(workspace_id) or !workspaceNameComponentValid(model_fingerprint) or + vector_dim <= 0 or out_values == null or out_len != sizeOfInt(vector_dim)) return -1; + found.* = false; + const db = vector_session.zdb.db; + var collection: [COLLECTION_NAME_CAP]u8 = undefined; + if (cbm_zova_workspace_token_vector_collection_name(workspace_id, model_fingerprint, vector_dim, &collection, collection.len) != 0) return -1; + var vector_id: [32]u8 = undefined; + { + const statement = prepareZova(db, "SELECT m.token_key FROM cbm_token_vector_metadata_v1 m " ++ + "JOIN cbm_workspace_registry w ON w.workspace_key=m.workspace_key " ++ + "WHERE w.workspace_id=?1 AND m.token=?2", "token_vector.key_prepare") orelse return -1; + defer finalize(statement); + if (!workspaceBindText(db, statement, 1, workspace_id.?, "token_vector.key_workspace") or + !workspaceBindText(db, statement, 2, token_text, "token_vector.key_token")) return -1; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!statusOk(stepStatement(statement, &step), db, "token_vector.key_step")) return -1; + if (step == c.ZOVA_STEP_DONE) return 0; + var token_key: i64 = 0; + if (step != c.ZOVA_STEP_ROW or + !statusOk(columnI64(statement, 0, &token_key), db, "token_vector.key_value") or + !userPrivateKeyId(token_key, &vector_id)) return -1; + } + var vector: c.zova_vector = .{}; + const status = c.zova_vector_get(&c.zova_vector_get_request{ + .db = db, + .collection_name = &collection, + .vector_id = &vector_id, + .out_vector = &vector, + }); + if (status == c.ZOVA_VECTOR_NOT_FOUND) return 0; + if (status != c.ZOVA_OK) return -1; + defer c.zova_vector_free(&vector); + if (vector.element_type != c.ZOVA_VECTOR_ELEMENT_TYPE_I8 or vector.values_len != out_len or + vector.i8_values == null) return -1; + @memcpy(out_values[0..out_len], vector.i8_values[0..out_len]); + found.* = true; + return 0; +} + +pub export fn cbm_zova_vector_session_close(session: ?*VectorSession) void { + if (!with_zova) return; + const vector_session = session orelse return; + closeZova(&vector_session.zdb); + c.free(vector_session); +} + +pub export fn cbm_zova_vector_session_generation(session: ?*const VectorSession, out_generation: ?*i64) c_int { + if (!with_zova) { + if (out_generation) |out| out.* = 0; + return -1; + } + const vector_session = session orelse return -1; + const out = out_generation orelse return -1; + if (!vector_session.generation_valid) return -1; + out.* = vector_session.generation; + return 0; +} + +pub export fn cbm_zova_vector_session_has_workspace(session: ?*const VectorSession, workspace_id: ?Str, out_present: ?*bool) c_int { + if (!with_zova) { + if (out_present) |present| present.* = false; + return -1; + } + const vector_session = session orelse return -1; + const present = out_present orelse return -1; + if (!workspaceNameComponentValid(workspace_id)) return -1; + present.* = false; + const db = vector_session.zdb.db; + const sql: Str = if (workspaceMetadataTableExists(db, "cbm_nodes_v1")) + "SELECT 1 FROM cbm_nodes_v1 n JOIN cbm_workspace_registry r " ++ + "ON r.workspace_key=n.workspace_key WHERE r.workspace_id=?1 LIMIT 1" + else if (workspaceMetadataTableExists(db, "cbm_zova_generation_integrity_v1")) + "SELECT 1 FROM cbm_zova_generation_integrity_v1 " ++ + "WHERE workspace_id=?1 LIMIT 1" + else + return 0; + const stmt = prepareZova(db, sql, "vector_session.workspace_prepare") orelse return -1; + defer finalize(stmt); + if (!workspaceBindText(db, stmt, 1, workspace_id.?, "vector_session.workspace_bind")) return -1; + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!statusOk(stepStatement(stmt, &step), db, "vector_session.workspace_step") or + (step != c.ZOVA_STEP_ROW and step != c.ZOVA_STEP_DONE)) return -1; + present.* = step == c.ZOVA_STEP_ROW; + return 0; +} + +// ── Graph session ─────────────────────────────────────────────── + +const GraphVisitIndex = extern struct { + node_id: CStr = null, + index: usize = 0, +}; + +fn graphVisitIndexCompare(left: ?*const anyopaque, right: ?*const anyopaque) callconv(.c) c_int { + return c.strcmp(cast(GraphVisitIndex, left).node_id, cast(GraphVisitIndex, right).node_id); +} + +/// The position in the visit list of `node_id`; NULL when `items`, sorted +/// by node id, does not hold it exactly once. +fn graphVisitFind(items: []const GraphVisitIndex, node_id: CStr) ?usize { + const key: GraphVisitIndex = .{ .node_id = node_id }; + const hit = c.bsearch(&key, items.ptr, items.len, @sizeOf(GraphVisitIndex), graphVisitIndexCompare) orelse return null; + const at = (@intFromPtr(hit) - @intFromPtr(items.ptr)) / @sizeOf(GraphVisitIndex); + if ((at > 0 and c.strcmp(items[at - 1].node_id, node_id) == 0) or + (at + 1 < items.len and c.strcmp(items[at + 1].node_id, node_id) == 0)) return null; + return items[at].index; +} + +fn graphRecordElapsed(start: *const c.struct_timespec, out_ms: *f64) void { + const end = monotonicNow(); + out_ms.* += userPublishElapsedMs(start, &end); +} + +/// Fills name, file, label, lines and properties of every visit from +/// cbm_nodes_v1. `index` is the visits sorted by node id. +fn graphHydrateVisits(db: ?*Db, workspace_id: Str, visits: []GraphVisit, index: []const GraphVisitIndex, metrics: ?*GraphMetrics) bool { + const count = visits.len; + const filled = callocList(bool, count); + defer c.free(filled); + const node_keys = callocList(i64, count); + defer c.free(node_keys); + if (filled == null or node_keys == null) return false; + if (!workspaceMetadataTableExists(db, "cbm_nodes_v1")) return false; + const workspace_key = workspaceKeyForId(db, workspace_id) orelse return false; + var graph_name: [GRAPH_NAME_CAP]u8 = undefined; + if (cbm_zova_workspace_graph_name(workspace_id, &graph_name, graph_name.len) != 0) return false; + + var cursor: c.zova_graph_scan_cursor = .{}; + var more = true; + while (more) { + var scan: c.zova_graph_scan_results = .{}; + defer c.zova_graph_scan_results_free(&scan); + if (c.zova_graph_scan(&c.zova_graph_scan_request{ + .db = db, + .graph_name = &graph_name, + .node_after = cursor, + .node_limit = 4096, + .edge_limit = 0, + .out_results = &scan, + }) != c.ZOVA_OK) return false; + const nodes = many(scan.nodes, scan.nodes_len); + for (nodes) |*node| { + if (graphVisitFind(index, node.node_id)) |visit_index| node_keys[visit_index] = node.node_key; + } + more = scan.has_more_nodes != 0; + if (nodes.len != 0) { + const last = &nodes[nodes.len - 1]; + cursor.created_order = last.created_order; + cursor.key = last.node_key; + } else if (more) { + return false; + } + } + + var offset: usize = 0; + while (offset < count) : (offset += ZV_GRAPH_HYDRATE_BATCH) { + const batch_keys: []const i64 = node_keys[offset..@min(count, offset + ZV_GRAPH_HYDRATE_BATCH)]; + for (batch_keys) |key| { + if (key <= 0) return false; + } + { + var keyed: c.zova_graph_keyed_node_results = .{}; + defer c.zova_graph_keyed_node_results_free(&keyed); + if (c.zova_graph_nodes_get_many_keyed(&c.zova_graph_nodes_get_many_keyed_request{ + .db = db, + .graph_name = &graph_name, + .node_keys = batch_keys.ptr, + .key_count = batch_keys.len, + .out_results = &keyed, + }) != c.ZOVA_OK or keyed.len != batch_keys.len) return false; + for (many(keyed.items, keyed.len), batch_keys, visits[offset..][0..batch_keys.len]) |*item, key, *visit| { + if (item.found == 0 or item.node_key != key or c.strcmp(item.node_id, visit.node_id) != 0) return false; + } + } + + var sql: [ZV_SQL_MAX * 8]u8 = undefined; + var length = interop.bprint(&sql, "SELECT n.zova_node_key,n.name,n.qualified_name,f.file_path,n.label,n.start_line," ++ + "n.end_line,n.properties FROM cbm_nodes_v1 n " ++ + "JOIN cbm_files_v1 f ON f.file_key=n.file_key " ++ + "WHERE n.workspace_key=?1 AND n.zova_node_key IN (", .{}); + if (length >= sql.len) return false; + for (0..batch_keys.len) |i| { + const written = interop.bprint(sql[length..], "{s}?{d}", .{ if (i == 0) "" else ",", i + 2 }); + if (written >= sql.len - length) return false; + length += written; + } + if (interop.bprint(sql[length..], ")", .{}) >= sql.len - length) return false; + + var stage_start = monotonicNow(); + const stmt = prepareZova(db, bufZ(&sql), "graph.hydrate_prepare"); + defer finalize(stmt); + if (metrics) |graph_metrics| graphRecordElapsed(&stage_start, &graph_metrics.hydrate_prepare_ms); + if (stmt == null or !workspaceBindI64(db, stmt, 1, workspace_key, "graph.hydrate_bind_workspace")) return false; + for (batch_keys, 2..) |key, parameter| { + if (!workspaceBindI64(db, stmt, @intCast(parameter), key, "graph.hydrate_bind_node")) return false; + } + stage_start = monotonicNow(); + while (true) { + var step: c.zova_step_result = c.ZOVA_STEP_DONE; + if (!statusOk(stepStatement(stmt, &step), db, "graph.hydrate_step")) return false; + if (step == c.ZOVA_STEP_DONE) break; + var node_key: i64 = 0; + if (!statusOk(columnI64(stmt, 0, &node_key), db, "graph.hydrate_node_key")) return false; + const in_batch = for (batch_keys, 0..) |key, i| { + if (key == node_key) break i; + } else return false; + const visit_index = offset + in_batch; + if (filled[visit_index]) return false; + const visit = &visits[visit_index]; + visit.name = columnTextOwned(db, stmt, 1); + visit.qualified_name = columnTextOwned(db, stmt, 2); + visit.file_path = columnTextOwned(db, stmt, 3); + visit.label = columnTextOwned(db, stmt, 4); + visit.properties = columnTextOwned(db, stmt, 7); + var start_line: i64 = 0; + var end_line: i64 = 0; + if (visit.name == null or visit.qualified_name == null or visit.file_path == null or visit.label == null or + visit.properties == null or + !statusOk(columnI64(stmt, 5, &start_line), db, "graph.hydrate_start") or + !statusOk(columnI64(stmt, 6, &end_line), db, "graph.hydrate_end")) return false; + visit.start_line = std.math.cast(c_int, start_line) orelse return false; + visit.end_line = std.math.cast(c_int, end_line) orelse return false; + filled[visit_index] = true; + } + if (metrics) |graph_metrics| graphRecordElapsed(&stage_start, &graph_metrics.hydrate_step_ms); + } + for (filled[0..count]) |done| { + if (!done) return false; + } + return true; +} + +fn graphNativeProfileEnabled() bool { + const value: CStr = c.getenv("CBM_ZOVA_GRAPH_PROFILE"); + return value != null and c.strcmp(value, "1") == 0; +} + +fn graphMetricsCopyNativeProfile(metrics: *GraphMetrics, profile: *const c.zova_graph_walk_profile) void { + metrics.native_profiled = true; + metrics.native_mutex_wait_ms = profile.mutex_wait_ms; + metrics.native_root_lookup_ms = profile.root_lookup_ms; + metrics.native_adjacency_prepare_ms = profile.adjacency_prepare_ms; + metrics.native_adjacency_execute_ms = profile.adjacency_execute_ms; + metrics.native_bfs_bookkeeping_allocation_ms = profile.bfs_bookkeeping_allocation_ms; + metrics.native_c_abi_result_export_ms = profile.c_abi_result_export_ms; + metrics.native_total_profiled_ms = profile.total_profiled_ms; + metrics.frontier_expansions = profile.frontier_expansions; + metrics.adjacency_query_binds = profile.adjacency_query_binds; + metrics.adjacency_rows_stepped = profile.adjacency_rows_stepped; + metrics.native_result_count = profile.result_count; +} + +pub export fn cbm_zova_graph_session_open(zova_path: ?Str) ?*GraphSession { + if (!with_zova) return null; + return sessionOpen(GraphSession, zova_path); +} + +pub export fn cbm_zova_graph_session_close(session: ?*GraphSession) void { + if (!with_zova) return; + const graph_session = session orelse return; + closeZova(&graph_session.zdb); + c.free(graph_session); +} + +pub export fn cbm_zova_graph_session_generation(session: ?*const GraphSession, out_generation: ?*i64) c_int { + if (!with_zova) { + if (out_generation) |out| out.* = 0; + return -1; + } + const graph_session = session orelse return -1; + const out = out_generation orelse return -1; + if (!graph_session.generation_valid) return -1; + out.* = graph_session.generation; + return 0; +} + +pub export fn cbm_zova_graph_session_walk_calls( + session: ?*GraphSession, + workspace_id: ?Str, + graph_name: ?Str, + start_node_id: ?Str, + direction: ?Str, + max_depth: c_int, + max_results: c_int, + out_visits: ?*[*c]GraphVisit, + out_count: ?*c_int, + out_metrics: ?*GraphMetrics, +) c_int { + if (out_visits) |visits| visits.* = null; + if (out_count) |count| count.* = 0; + if (out_metrics) |metrics| { + metrics.* = std.mem.zeroes(GraphMetrics); + metrics.fallback = true; + } + if (!with_zova) return -1; + const graph_session = session orelse return -1; + const graph = nonEmpty(graph_name) orelse return -1; + const start_node = nonEmpty(start_node_id) orelse return -1; + const direction_name = direction orelse return -1; + if (!workspaceNameComponentValid(workspace_id) or max_depth < 0 or max_results <= 0 or + out_visits == null or out_count == null or max_results == INT_MAX) return -1; + const zova_direction: c_int = if (c.strcmp(direction_name, "inbound") == 0) + c.ZOVA_GRAPH_NEIGHBOR_INCOMING + else if (c.strcmp(direction_name, "outbound") == 0) + c.ZOVA_GRAPH_NEIGHBOR_OUTGOING + else + return -1; + const db = graph_session.zdb.db; + var results: c.zova_graph_walk_results = .{}; + defer c.zova_graph_walk_results_free(&results); + const limit = sizeOfInt(max_results) + 1; + var stage_start = monotonicNow(); + var ok: bool = undefined; + if (graphNativeProfileEnabled()) { + var profile: c.zova_graph_walk_profile = .{}; + ok = statusOk(c.zova_graph_walk_direction_profiled(&c.zova_graph_walk_direction_profiled_request{ + .db = db, + .graph_name = graph, + .start_node_id = start_node, + .direction = zova_direction, + .edge_type = "CALLS", + .max_depth = @intCast(max_depth), + .limit = limit, + .out_results = &results, + .out_profile = &profile, + }), db, "graph.walk_calls_profiled"); + if (out_metrics) |metrics| graphMetricsCopyNativeProfile(metrics, &profile); + } else { + ok = statusOk(c.zova_graph_walk_direction(&c.zova_graph_walk_direction_request{ + .db = db, + .graph_name = graph, + .start_node_id = start_node, + .direction = zova_direction, + .edge_type = "CALLS", + .max_depth = @intCast(max_depth), + .limit = limit, + .out_results = &results, + }), db, "graph.walk_calls"); + } + if (out_metrics) |metrics| graphRecordElapsed(&stage_start, &metrics.walk_ms); + const walked = many(results.items, results.len); + if (!ok or walked.len == 0 or c.strcmp(walked[0].node_id, start_node) != 0 or walked[0].depth != 0 or + walked.len > limit or walked.len - 1 > INT_MAX) return -1; + + const count = walked.len - 1; + const visits = interop.callocN(GraphVisit, count); + const index = interop.callocN(GraphVisitIndex, count); + defer c.free(index); + stage_start = monotonicNow(); + ok = count == 0 or (visits != null and index != null); + if (ok) { + for (walked[1..], 0..) |*item, i| { + if (item.node_id == null or item.node_id[0] == 0 or item.depth == 0 or item.depth > INT_MAX) { + ok = false; + break; + } + visits[i].node_id = zvStrdup(item.node_id); + visits[i].hop = @intCast(item.depth); + index[i] = .{ .node_id = visits[i].node_id, .index = i }; + if (visits[i].node_id == null) { + ok = false; + break; + } + } + } + if (ok and count > 1) { + c.qsort(index, count, @sizeOf(GraphVisitIndex), graphVisitIndexCompare); + for (1..count) |i| { + if (c.strcmp(index[i - 1].node_id, index[i].node_id) == 0) { + ok = false; + break; + } + } + } + if (out_metrics) |metrics| graphRecordElapsed(&stage_start, &metrics.result_build_ms); + if (ok and count > 0) { + ok = graphHydrateVisits(db, workspace_id.?, visits[0..count], index[0..count], out_metrics); + } + if (!ok) { + cbm_zova_graph_visits_free(visits, @intCast(count)); + return -1; + } + out_visits.?.* = visits; + out_count.?.* = @intCast(count); + if (out_metrics) |metrics| { + metrics.walk_count = 1; + metrics.fallback = false; + } + return 0; +} + +// ── Exports that only a Zova build defines ────────────────────── + +pub const zova_only = if (with_zova) struct { + pub export fn cbm_zova_database_format_status(path: ?Str) FormatStatus { + return databaseFormatStatus(path); + } + + pub export fn cbm_zova_repack_phase_name(phase: RepackPhase) ?Str { + if (phase < 0 or phase >= c.CBM_ZOVA_REPACK_PHASE_COUNT) return null; + return repack_phase_names[@intCast(phase)]; + } + + pub export fn cbm_zova_register_sql_functions(db: ?*Db) c_int { + return if (registerSqlFunctions(db)) 0 else -1; + } + + pub export fn cbm_zova_user_database_verify_workspace_schema(db: ?*Db) c_int { + return if (verifyWorkspaceSchema(db)) 0 else -1; + } + + pub export fn cbm_zova_user_database_schema_is_current(db: ?*Db) c_int { + return if (userDatabaseSchemaIsCurrent(db)) 0 else -1; + } + + /// Caller-owned transaction primitive. It never begins, commits, or + /// rolls back; the caller remains responsible for transaction lifetime. + pub export fn cbm_zova_user_database_publish_workspace_tx(db: ?*Db, input: ?*const GenerationInput, out_result: ?*GenerationResult) c_int { + return if (userDatabasePublishWorkspaceTx(db, input, out_result)) 0 else -1; + } + + pub export fn cbm_zova_user_database_publish_model_tx(db: ?*Db, model: ?*const PublishModel, requested_generation: i64, out_result: ?*GenerationResult) c_int { + return if (userDatabasePublishModelTx(db, model, requested_generation, out_result)) 0 else -1; + } +} else struct {}; From ebb516754acb62a3179ca825f1b8ddec3a1c32b7 Mon Sep 17 00:00:00 2001 From: ata-sesli Date: Wed, 11 Nov 2026 14:11:14 +0300 Subject: [PATCH 9/9] build(make): link libcbm_zig.a before the Zova library GNU ld takes from a static library only the symbols that earlier inputs still need. Since the Zova callers moved from C sources into libcbm_zig.a, libzova_c.a came before its only user and the Linux release link failed with undefined zova_* references. List the Zig library first in the release, test-runner and test-repro-runner links. Signed-off-by: ata-sesli --- Makefile.cbm | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile.cbm b/Makefile.cbm index 8205b2a7..a58c32d9 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -700,8 +700,8 @@ $(BUILD_DIR)/test-runner: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_ $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) \ $(OBJS_VENDORED_TEST) \ $(CBM_ZOVA_BRIDGE_LIB) \ - $(ZOVA_LINK_LIBS) \ $(CBM_ZIG_TEST_LIB) \ + $(ZOVA_LINK_LIBS) \ $(LDFLAGS_TEST) test: @@ -735,8 +735,8 @@ $(BUILD_DIR)/test-repro-runner: $(TEST_REPRO_SRCS) $(PROD_SRCS) $(EXTRACTION_SRC $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) \ $(OBJS_VENDORED_TEST) \ $(CBM_ZOVA_BRIDGE_LIB) \ - $(ZOVA_LINK_LIBS) \ $(CBM_ZIG_TEST_LIB) \ + $(ZOVA_LINK_LIBS) \ $(LDFLAGS_TEST) test-repro: $(BUILD_DIR)/test-repro-runner @@ -789,8 +789,8 @@ $(BUILD_DIR)/codebase-memory-mcp: $(MAIN_SRC) $(PROD_SRCS) $(EXTRACTION_SRCS) $( $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) \ $(OBJS_VENDORED_PROD) \ $(CBM_ZOVA_BRIDGE_LIB) \ - $(ZOVA_LINK_LIBS) \ $(CBM_ZIG_LIB) \ + $(ZOVA_LINK_LIBS) \ $(LDFLAGS) cbm: