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 be5c4d01..a58c32d9 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -149,32 +149,17 @@ 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 = \ - 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 +# 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 = # 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 +252,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) $(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) @@ -474,6 +459,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 \ @@ -492,10 +478,29 @@ 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 + 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 \ + 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 \ + $(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 $(ZOVA_ROOT)/include/zova_plugin.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. @@ -695,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: @@ -730,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 @@ -784,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: @@ -819,8 +824,8 @@ 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) \ - $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SEMANTIC_SRCS) \ +LINT_SRCS = $(FOUNDATION_SRCS) $(MCP_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..c1023226 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", @@ -532,20 +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/store/store.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/graph_buffer/graph_buffer.c", "src/pipeline/fqn.c", "src/pipeline/path_alias.c", "src/pipeline/laravel_routing.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 9dbbe776..36eeb008 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")); @@ -61,13 +62,27 @@ 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")); + 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")); + 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_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")); } /// 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/cbm_zig_c.h b/src/cbm_zig_c.h index e4b2dd70..473c2025 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 + * 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 #define CBM_ZIG_C_H @@ -21,6 +22,7 @@ #ifndef _WIN32 #include #include +#include #include #include #include @@ -91,12 +93,26 @@ #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" +#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_plugin.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 +#include +#endif #include #include @@ -121,6 +137,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; +} 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/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 {}; 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_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/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; +} 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/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; +} 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); +} diff --git a/tests/test_github_workflows.sh b/tests/test_github_workflows.sh index 774a58e6..3b3e695a 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" @@ -138,8 +138,8 @@ 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/zova/cbm_zova_operations.c" \ + "$ROOT/src/store/store.zig" \ + "$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