Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/lib/db/caseMapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* db/caseMapping.ts — pure snake_case ↔ camelCase column mapping.
*
* Extracted from db/core.ts (god-file decomposition): the column-name conversion
* helpers that translate raw SQLite rows (snake_case columns, 0/1 booleans, `_json`
* TEXT columns) into the camelCase shapes the domain modules consume. Pure — no DB
* handle, no module state — so they live as a co-located leaf that every db/ module
* (and core.ts itself) imports. core.ts re-exports all five so existing call sites that
* pull these helpers off the core module keep working unchanged.
*/

type JsonRecord = Record<string, unknown>;

export function toSnakeCase(str: string): string {
return str.replace(/([A-Z])/g, "_$1").toLowerCase();
}

export function toCamelCase(str: string): string {
return str.replace(/_([a-z])/g, (_: string, c: string) => c.toUpperCase());
}

export function objToSnake(obj: unknown): unknown {
if (!obj || typeof obj !== "object") return obj;
const result: JsonRecord = {};
for (const [k, v] of Object.entries(obj as JsonRecord)) {
result[toSnakeCase(k)] = v;
}
return result;
}
Comment on lines +22 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The objToSnake function does not handle arrays. Since typeof [] === "object" is true in JavaScript, passing an array to objToSnake will cause it to be converted into an object with numeric string keys (e.g., [{foo: 1}] becomes {"0": {foo: 1}}). We should explicitly check for arrays and recursively map over them or return them as-is.

export function objToSnake(obj: unknown): unknown {
  if (!obj || typeof obj !== "object") return obj;
  if (Array.isArray(obj)) {
    return obj.map(objToSnake);
  }
  const result: JsonRecord = {};
  for (const [k, v] of Object.entries(obj as JsonRecord)) {
    result[toSnakeCase(k)] = v;
  }
  return result;
}


export function rowToCamel(row: unknown): JsonRecord | null {
if (!row) return null;
const result: JsonRecord = {};
for (const [k, v] of Object.entries(row as JsonRecord)) {
const camelKey = toCamelCase(k);
if (
camelKey === "isActive" ||
camelKey === "rateLimitProtection" ||
camelKey === "proxyEnabled" ||
camelKey === "perKeyProxyEnabled"
) {
result[camelKey] = v === 1 || v === true;
} else if (camelKey === "providerSpecificData" && typeof v === "string") {
try {
result[camelKey] = JSON.parse(v);
} catch {
result[camelKey] = v;
}
} else if (camelKey.endsWith("Json")) {
// Convention: any column with a `_json` suffix is JSON-encoded TEXT.
// Surface the parsed object under the friendlier name (key minus the
// "Json" suffix) — e.g. quotaWindowThresholdsJson → quotaWindowThresholds.
// A NULL/absent column normalizes to `baseKey: null` (not the suffixed
// key) so read and write paths expose a consistent shape.
const baseKey = camelKey.slice(0, -"Json".length);
if (typeof v === "string") {
try {
result[baseKey] = JSON.parse(v);
} catch {
result[baseKey] = null;
}
} else {
result[baseKey] = v == null ? null : v;
}
} else {
result[camelKey] = v;
}
}
return result;
}

export function cleanNulls(obj: unknown): JsonRecord {
const result: JsonRecord = {};
for (const [k, v] of Object.entries((obj as JsonRecord) || {})) {
if (v !== null && v !== undefined) {
result[k] = v;
}
}
return result;
}
Comment on lines +72 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The cleanNulls function does not guard against arrays or primitive values (like strings or numbers). If a string is passed, Object.entries("abc") will return index-based entries, converting the string into an object. We should add a guard to ensure only plain objects are processed.

Suggested change
export function cleanNulls(obj: unknown): JsonRecord {
const result: JsonRecord = {};
for (const [k, v] of Object.entries((obj as JsonRecord) || {})) {
if (v !== null && v !== undefined) {
result[k] = v;
}
}
return result;
}
export function cleanNulls(obj: unknown): JsonRecord {
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
return {};
}
const result: JsonRecord = {};
for (const [k, v] of Object.entries(obj as JsonRecord)) {
if (v !== null && v !== undefined) {
result[k] = v;
}
}
return result;
}

73 changes: 3 additions & 70 deletions src/lib/db/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,21 @@
* All domain modules import `getDbInstance` and helpers from here.
*/

import type { SqliteAdapter } from "./adapters/types";

Check failure on line 7 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './adapters/types.js'?
import {
tryOpenSync,
getSqlJsAdapter,
preInitSqlJs,
openDatabaseAsync,
} from "./adapters/driverFactory";

Check failure on line 13 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './adapters/driverFactory.js'?
import path from "path";
import fs from "fs";
import { resolveDataDir, getLegacyDotDataDir } from "../dataPaths";

Check failure on line 16 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean '../dataPaths.js'?
import { runMigrations } from "./migrationRunner";

Check failure on line 17 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './migrationRunner.js'?
import { runDbHealthCheck } from "./healthCheck";

Check failure on line 18 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './healthCheck.js'?
import { resetAllDbModuleState } from "./stateReset";

Check failure on line 19 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './stateReset.js'?
import { parseStoredPayload } from "../logPayloads";

Check failure on line 20 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean '../logPayloads.js'?
import { DEFAULT_DATABASE_SETTINGS, type DatabaseSettings } from "@/types/databaseSettings";

Check failure on line 21 in src/lib/db/core.ts

View workflow job for this annotation

GitHub Actions / dast-smoke

Cannot find module '@/types/databaseSettings' or its corresponding type declarations.
import {
applyDatabaseOptimizationSettingsForDb,
applyStoredDatabaseOptimizationSettings,
Expand All @@ -34,6 +34,9 @@
} from "../usage/callLogArtifacts";
import { migrateLegacyEncryptedString } from "./encryption";
import { invalidateDbCache } from "./readCache";
import { rowToCamel } from "./caseMapping";
// Re-exported so existing call sites that pull these helpers off the core module keep working.
export { toSnakeCase, toCamelCase, objToSnake, rowToCamel, cleanNulls } from "./caseMapping";

type SqliteDatabase = SqliteAdapter;
type JsonRecord = Record<string, unknown>;
Expand Down Expand Up @@ -444,76 +447,6 @@
CREATE INDEX IF NOT EXISTS idx_quota_snapshots_created_at ON quota_snapshots(created_at);
`;

// ──────────────── Column Mapping ────────────────

export function toSnakeCase(str: string): string {
return str.replace(/([A-Z])/g, "_$1").toLowerCase();
}

export function toCamelCase(str: string): string {
return str.replace(/_([a-z])/g, (_: string, c: string) => c.toUpperCase());
}

export function objToSnake(obj: unknown): unknown {
if (!obj || typeof obj !== "object") return obj;
const result: JsonRecord = {};
for (const [k, v] of Object.entries(obj as JsonRecord)) {
result[toSnakeCase(k)] = v;
}
return result;
}

export function rowToCamel(row: unknown): JsonRecord | null {
if (!row) return null;
const result: JsonRecord = {};
for (const [k, v] of Object.entries(row as JsonRecord)) {
const camelKey = toCamelCase(k);
if (
camelKey === "isActive" ||
camelKey === "rateLimitProtection" ||
camelKey === "proxyEnabled" ||
camelKey === "perKeyProxyEnabled"
) {
result[camelKey] = v === 1 || v === true;
} else if (camelKey === "providerSpecificData" && typeof v === "string") {
try {
result[camelKey] = JSON.parse(v);
} catch {
result[camelKey] = v;
}
} else if (camelKey.endsWith("Json")) {
// Convention: any column with a `_json` suffix is JSON-encoded TEXT.
// Surface the parsed object under the friendlier name (key minus the
// "Json" suffix) — e.g. quotaWindowThresholdsJson → quotaWindowThresholds.
// A NULL/absent column normalizes to `baseKey: null` (not the suffixed
// key) so read and write paths expose a consistent shape.
const baseKey = camelKey.slice(0, -"Json".length);
if (typeof v === "string") {
try {
result[baseKey] = JSON.parse(v);
} catch {
result[baseKey] = null;
}
} else {
result[baseKey] = v == null ? null : v;
}
} else {
result[camelKey] = v;
}
}
return result;
}

export function cleanNulls(obj: unknown): JsonRecord {
const result: JsonRecord = {};
for (const [k, v] of Object.entries((obj as JsonRecord) || {})) {
if (v !== null && v !== undefined) {
result[k] = v;
}
}
return result;
}

// ──────────────── Singleton DB Instance ────────────────
// Use globalThis to survive Next.js dev HMR module re-evaluation.
// Module-level `let` resets on every webpack recompile, causing connection leaks.
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/db-case-mapping-split.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Characterization of the db/core.ts column-mapping split (god-file decomposition): the pure
// snake_case ↔ camelCase row helpers moved into db/caseMapping.ts. Behavior-preserving move — these
// locks pin the conversion semantics (boolean coercion for known flag columns, JSON parsing for
// providerSpecificData + `_json` TEXT columns, null-stripping) and that core.ts still re-exports them
// for the historical `from "./core"` import surface.
import { test } from "node:test";
import assert from "node:assert/strict";

const M = await import("../../src/lib/db/caseMapping.ts");
const CORE = await import("../../src/lib/db/core.ts");

test("module exposes the five mapping helpers", () => {
for (const name of ["toSnakeCase", "toCamelCase", "objToSnake", "rowToCamel", "cleanNulls"]) {
assert.equal(typeof (M as Record<string, unknown>)[name], "function", `missing ${name}`);
}
});

test("core.ts re-exports the same helpers (historical surface preserved)", () => {
for (const name of ["toSnakeCase", "toCamelCase", "objToSnake", "rowToCamel", "cleanNulls"]) {
assert.equal((CORE as Record<string, unknown>)[name], (M as Record<string, unknown>)[name]);
}
});

test("toSnakeCase / toCamelCase round-trip column names", () => {
assert.equal(M.toSnakeCase("maxRequestsPerDay"), "max_requests_per_day");
assert.equal(M.toCamelCase("max_requests_per_day"), "maxRequestsPerDay");
});

test("objToSnake converts keys, passes non-objects through", () => {
assert.deepEqual(M.objToSnake({ providerId: "x", isActive: 1 }), {
provider_id: "x",
is_active: 1,
});
assert.equal(M.objToSnake(null), null);
assert.equal(M.objToSnake("str"), "str");
});

test("rowToCamel coerces known boolean flags from 0/1", () => {
const out = M.rowToCamel({ is_active: 1, proxy_enabled: 0, per_key_proxy_enabled: 1 });
assert.equal(out?.isActive, true);
assert.equal(out?.proxyEnabled, false);
assert.equal(out?.perKeyProxyEnabled, true);
assert.equal(M.rowToCamel(null), null);
});

test("rowToCamel parses providerSpecificData JSON string", () => {
const out = M.rowToCamel({ provider_specific_data: '{"a":1}' });
assert.deepEqual(out?.providerSpecificData, { a: 1 });
// invalid JSON falls back to the raw string
const bad = M.rowToCamel({ provider_specific_data: "not json" });
assert.equal(bad?.providerSpecificData, "not json");
});

test("rowToCamel unwraps `_json` columns to the base key", () => {
const out = M.rowToCamel({ quota_window_thresholds_json: '{"hi":80}' });
assert.deepEqual(out?.quotaWindowThresholds, { hi: 80 });
assert.equal("quotaWindowThresholdsJson" in (out ?? {}), false);
// NULL `_json` column normalizes the base key to null
const nul = M.rowToCamel({ quota_window_thresholds_json: null });
assert.equal(nul?.quotaWindowThresholds, null);
});

test("cleanNulls drops null/undefined, keeps falsy-but-present values", () => {
assert.deepEqual(M.cleanNulls({ a: 0, b: "", c: null, d: undefined, e: false }), {
a: 0,
b: "",
e: false,
});
assert.deepEqual(M.cleanNulls(null), {});
});
Loading