/** * Shared column-encoder helpers for the polymorphic CodeNode table. * * Both `DuckDbStore` (`./duckdb-adapter.ts`) and `GraphDbStore` * (`./graphdb-adapter.ts`) write a 73-column row per node where every column * matches the canonical {@link NODE_COLUMNS} order. The two adapters used to * carry duplicate `nodeToRow` / `nodeToParams` / `*OrNull` / `dedupeLastById` * helpers; both now consume one canonical implementation here. * * The module is `internal-only` — it is NOT re-exported from * `packages/storage/src/index.ts`. Adapters import directly from * `./column-encode.js`. * * Three sentinel rules also live here, promoted from * `graph-hash-parity.test.ts`: * * - {@link stepZeroSentinel}: the DuckDB `relations.step` column is * `INTEGER NOT NULL DEFAULT 0`; the graph-db column is nullable `INT32`. * Both backends agree on dropping `step` when the stored value reads back * as zero/null so the round-trip is byte-identical. * - {@link coerceLanguageStats}: `RepoNode.languageStats = {}` is coerced * to SQL NULL on write and re-added as `{}` on read so the canonical-JSON * hash is stable across "absent" vs "explicitly empty". * - {@link applyRepoNullables}: `RepoNode.originUrl/defaultBranch/group` * are `string | null` on the interface, never `string | undefined`. When * reading a Repo row whose column is NULL, re-attach the field as * explicit `null` so canonical-JSON parity holds. * * Plus the deadness normalization {@link normalizeDeadness}: * - `unreachable-export → unreachable_export` on write, reverse on read * (the write side is exported here; the read side stays in each adapter * because it's symmetric with the per-adapter row decoder). * * **`stringArrayOrNull` round-trip note** — `[]` and * `undefined` are kept distinct on the wire. {@link stringArrayOrNull} * returns a typed 0-length array for an empty-array input and `null` for a * non-array input. The two backends preserve that distinction differently: * - DuckDB `TEXT[]` stores a 0-length array literal natively, so the * symmetric reader re-attaches `[]` as the field value. * - lbug `STRING[]` collapses a 0-length array to SQL NULL on write * (v0.16.1), so the graph-db adapter encodes an explicit empty array as * a single-element marker on write and decodes it back to `[]` on read * (`encodeNodeCol` + `setStringArrayFieldGd` in graphdb-adapter.ts); a * non-array input is written as `[]` → stored NULL → dropped (= absent). * The CLI read path (`stringArrayField` in analyze.ts) mirrors the DuckDB * reader. Net effect: `{keywords: []}` round-trips byte-identically to * itself instead of collapsing to `{}` (canonical-JSON / graphHash * distinction preserved on every backend). Enforced end-to-end by * `graph-hash-parity.test.ts`. * * **`frameworks_json` unification** — before the hoist, the DuckDB * adapter wrote the v2.0 polymorphic shape via `frameworksJsonOrNull` * while the graph-db adapter wrote the legacy flat shape via * `jsonArrayOrNull`. Both adapters' readers already support both shapes * (`applyFrameworksJsonReadback`, `applyFrameworksJsonReadbackGd`). The * unified writer here calls {@link frameworksJsonOrNull} for both adapters, * which emits the legacy flat array whenever `frameworksDetected` is absent * / empty (every existing fixture and every legacy graph), and the v2.0 * `{flat, detected}` envelope only when callers populate * `frameworksDetected`. The parity test stays green; production graphs that * never carried `frameworksDetected` round-trip byte-identically. */ import { type GraphNode } from "@opencodehub/core-types"; /** * Canonical column ordering for the polymorphic `nodes` / `CodeNode` table. * Both DuckDB and the graph-db backends consume this list — the type-name * mapping (`TEXT[]` vs `STRING[]`, etc.) lives in each adapter's CREATE * TABLE DDL, but the column ORDER is canonical and shared. * * Rules for adding a column (must hold across both adapters): * 1. Append to the END of this list — reordering rewrites every prepared * statement parameter slot and breaks already-persisted graphs. * 2. Append the writer in {@link nodeToColumns}. * 3. Append the reader in each adapter's row decoder (`rowToGraphNode` * for DuckDB, `applyNodeColumns` + `ROUND_TRIP_COLUMN_MAP` for * graph-db). * 4. Update the CREATE TABLE DDL in `schema-ddl.ts` (DuckDB) and * `graphdb-schema.ts` (graph-db) to keep the on-disk schema in lock * step with this list. */ export declare const NODE_COLUMNS: readonly string[]; /** * Encode a GraphNode into a `column → value` map indexed by the canonical * {@link NODE_COLUMNS} keys. Each adapter consumes this map and projects to * its own native binding (DuckDB row tuple / graph-db parameter list). * * Field/column aliasing: * - `OperationNode.method` → `http_method` column (not `method`, which is * reserved for `RouteNode`). * - `OperationNode.path` → `http_path`. * The Operation write-through still preserves read-back determinism * because each adapter's row decoder maps `http_method`/`http_path` back * to `method`/`path` when `kind === "Operation"`. * * Defensive bracket-access on the source node lets unknown / future * NodeKinds fall through to NULL-valued columns without throwing. */ export declare function nodeToColumns(node: GraphNode): Record; /** * Dedupe by the caller-provided id extractor, keeping the LAST occurrence. * * Protects against DuckDB UPSERT issue 8147 (two rows with the same primary * key in one INSERT cannot both fire ON CONFLICT). The caller-driven id * function also lets us reuse this for nodes (id) and edges (id). */ export declare function dedupeLastById(items: readonly T[], idOf: (t: T) => string): readonly T[]; /** * Coerce a numeric value to `number` or `null`. NaN / Infinity / non-number * inputs collapse to `null` so downstream binders don't blow up on a * non-finite parameter. */ export declare function numberOrNull(v: unknown): number | null; /** * Coerce to a non-empty string or `null`. Empty strings collapse to NULL — * the storage layer treats "" and absent as equivalent. */ export declare function stringOrNull(v: unknown): string | null; /** Coerce to `boolean` or `null`. */ export declare function booleanOrNull(v: unknown): boolean | null; /** * Coerce to a `readonly string[]` or `null`. * * - Non-array inputs (`undefined`, `null`, wrong type) → `null` (= column * stays SQL NULL, reader drops the field, canonical-JSON omits the key). * - Array inputs round-trip as a typed array — including `[]` (0-length). * Non-string elements are filtered silently. * * **Preserve `[]` distinct from absent.** Returning a typed `[]` on an * empty-array input (rather than `null`) carries the "explicit empty" * signal into each adapter's writer. DuckDB `TEXT[]` stores a 0-length * literal natively; lbug `STRING[]` cannot (it collapses `[]` to NULL on * write), so the graph-db adapter substitutes an empty-array marker on the * way in and decodes it back on the way out — see `encodeNodeCol` + * `setStringArrayFieldGd` in `graphdb-adapter.ts`. The symmetric reader * change in `duckdb-adapter.ts:setStringArrayField` and * `analyze.ts:stringArrayField` re-attaches `[]` instead of dropping the * field when the read-back array has length zero. Combined, this preserves * the canonical-JSON shape difference between `{keywords: []}` and `{}` * (graphHash content-shape change — see the empty-keywords fixture in * `graph-hash-parity.test.ts`). */ export declare function stringArrayOrNull(v: unknown): readonly string[] | null; /** * Serialize an array of primitives or arbitrary JSON-safe records to a JSON * string. Returns `null` for any input that is not an array. Object values * are serialized verbatim via `JSON.stringify`. Pre-canonicalized strings * pass through unchanged so callers can pre-encode. */ export declare function jsonArrayOrNull(v: unknown): string | null; /** * Serialize a `Record` (or pre-encoded JSON string) into a * JSON string for storage in a polymorphic TEXT column. Returns `null` for * null / undefined / non-object / array inputs. */ export declare function jsonObjectOrNull(v: unknown): string | null; /** * Resolve the value for the `covered_lines_json` column. File nodes carry a * `coveredLines: readonly number[]` field (flattened via canonical JSON); * callables carry an already-serialized `coveredLinesJson` string. Prefer * the string when present so we don't re-stringify work the caller already * did. */ export declare function coveredLinesOrNull(coveredLines: unknown, coveredLinesJson: unknown): string | null; /** * Resolve a `RepoNode` field whose interface-level type is `string | null`. * * `stringOrNull` already collapses null and empty strings alike to SQL * NULL. `repoStringOrNull` is named the same way at the call site so future * editors recognise that the explicit-null preservation is a Repo-specific * concern handled on the read side via {@link applyRepoNullables}. */ export declare function repoStringOrNull(n: Record, key: string): string | null; /** * Serialize `RepoNode.languageStats` (`Record`) to * byte-stable canonical JSON (sorted keys — matches graphHash). Returns * `null` for non-object / empty inputs so the column stays NULL for non-Repo * rows AND for Repo rows whose stats are explicitly empty (the empty-stats * sentinel — readers re-add `{}` via {@link coerceLanguageStats}). */ export declare function languageStatsJsonOrNull(v: unknown): string | null; /** * Translate the hyphenated `unreachable-export` produced by the dead-code * analysis helper into the underscored form the `deadness` column stores. * Every other value (`live` / `dead`) already matches the schema enum. * * Each adapter carries the inverse `denormalizeDeadness` privately because * it's symmetric with the row decoder. */ export declare function normalizeDeadness(v: unknown): unknown; /** * Serialize the polymorphic `frameworks_json` column. * * Two on-disk shapes coexist: * - Legacy v1.0 graphs (before P05) wrote a flat `string[]` via * `jsonArrayOrNull`. Reader code accepts that shape unchanged. * - v2.0 graphs (after P05) write `{ flat: string[], detected: FrameworkDetection[] }`. * * The encoding is JSON in both cases. When the node carries no structured * detections (`frameworksDetected` absent or empty) we emit the legacy * flat-array shape so existing read paths continue to work without a * version bump. The read side in `packages/mcp/src/tools/project-profile.ts` * sniffs the shape. * * Both adapters call this function. The graph-db writer previously * emitted only the legacy flat shape; with the unification it gains the * v2.0 envelope when callers populate `frameworksDetected`. The legacy * path is byte-identical to the old graph-db output, so existing graphs * keep round-tripping unchanged. * * When both `flat` is absent / non-array AND `detected` is empty, * return `null` so the column stays NULL for nodes that never declared * a `frameworks` field (every node kind except ProjectProfile, in * practice). Previously this branch returned `"[]"` for every node, * which polluted the polymorphic column and — once the public-interface * parity harness landed — broke graphHash byte-identity (the rebuilder * would re-attach `frameworks: []` on every rebuilt node). Callers that * intentionally write an explicit empty array (a ProjectProfile node * with `frameworks: []` and no detections) still emit `"[]"` because * `flat` is a real array. */ export declare function frameworksJsonOrNull(flat: unknown, detected: unknown): string | null; /** * Step-zero sentinel. The DuckDB `relations.step` column is * `INTEGER NOT NULL DEFAULT 0`; the graph-db column is nullable `INT32`. * Both backends therefore disagree on read-back when the source edge * carries an explicit `step: 0` (DuckDB returns `0`, graph-db returns * `null`). The convention is "drop step when it reads back as zero/null" * — this helper formalises that on the read side so canonical-JSON parity * holds across backends. * * Returns `undefined` for `0` / `null` / `undefined` (drop the field on * the rebuilt node). Returns the verbatim number for every other input. * Non-finite numbers also collapse to `undefined` so a corrupt row never * leaks NaN into the rebuilt graph. */ export declare function stepZeroSentinel(value: number | null | undefined): number | undefined; /** * Coerce the read-back value for `RepoNode.languageStats`. * * The writer ({@link languageStatsJsonOrNull}) collapses `{}` to SQL NULL. * On read the reconstructed node must carry an empty `{}` so the canonical * JSON hash is stable across "absent" vs "explicitly empty". This helper * implements the symmetric coercion: parse the JSON when the column is a * non-empty string; otherwise emit `{}`. Non-object / array payloads also * collapse to `{}` so a corrupt row never poisons the rebuilt graph. */ export declare function coerceLanguageStats(raw: unknown): Record; /** * Re-attach `RepoNode` nullable string fields (`originUrl`, `defaultBranch`, * `group`) on the rebuilt record when the underlying column is NULL. * * `RepoNode` declares those three fields as `string | null` (not * `string | undefined`), so the rebuilt node must carry an explicit `null` * rather than leaving the key off — otherwise the canonical-JSON hash * diverges from the original fixture. * * Also handles `languageStats`: when the JSON column is a non-empty string, * parse it via {@link coerceLanguageStats}; otherwise emit `{}` so the empty * sentinel round-trips correctly. * * `rec` is the raw row (column-name keyed); `base` is the rebuilt node * accumulator (camelCase keyed). No-op for non-Repo rows. */ export declare function applyRepoNullables(rec: Record, base: Record): void; //# sourceMappingURL=column-encode.d.ts.map