All files / api/src/connectors/libsql rows.ts

91.5% Statements 97/106
84.9% Branches 45/53
100% Functions 9/9
91.5% Lines 97/106

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177                            1x         1x 173x 2x 2x 171x 171x     1x 3x 1x 1x 3x 3x               1x 274x 37x 37x 274x       237x 237x 274x 1x 1x 274x   1x 42x                   1x 160x 160x 160x 160x 160x 160x   160x 1986x 271x 271x 271x 1986x 40x 40x 40x 1986x 391x 391x 391x 1986x 1986x   160x 160x               1x 1x 1x 1x 1x 1x 1x   1x 1x   1x 1x 1x 1x 1x               1x 2x 2x 2x 2x 2x 2x 2x   2x 1x 1x   1x   1x 1x 1x 1x 1x                   1x 41x 41x 41x   41x 230x   230x   230x     230x 54x   230x 230x 230x     230x   41x 41x  
import type { InValue, Row } from '@libsql/client';
 
/**
 * Conversions between SQLite storage types and the shapes the Zod schemas in
 * `@shared/types/data` expect.
 *
 * Postgres gives PostgREST typed JSON: `jsonb` arrives parsed, `boolean` as a
 * boolean, `timestamptz` as an ISO string. SQLite has none of those types, so
 * everything is stored as TEXT/INTEGER/REAL and reconstituted here. Keeping
 * that in one place means a column's storage decision and its read path cannot
 * drift apart.
 */
 
/** Timestamp format written by the schema's triggers and defaults. */
export const nowIso = (): string => new Date().toISOString();
 
// ----------------------------------------------------------------- to SQLite
 
/** JSON columns: objects, arrays, and the `TEXT[]`/`FLOAT[]` translations. */
export const toJsonColumn = (value: unknown): InValue => {
  if (value === undefined || value === null) {
    return null;
  }
  return JSON.stringify(value);
};
 
/** SQLite has no boolean type; the schema's CHECK constraints pin it to 0/1. */
export const toBoolColumn = (value: boolean | undefined | null): InValue => {
  if (value === undefined || value === null) {
    return null;
  }
  return value ? 1 : 0;
};
 
// --------------------------------------------------------------- from SQLite
 
/**
 * Parse a JSON column. NULL stays `null`, which is what PostgREST returns for
 * a NULL `jsonb` column and therefore what the Zod schemas expect.
 */
export const fromJson = <T>(value: unknown, fallback?: T): T | undefined => {
  if (value === null || value === undefined) {
    return fallback === undefined ? (null as T) : fallback;
  }
  if (typeof value !== 'string') {
    // libSQL can hand back an already-decoded value for some drivers.
    return value as T;
  }
  try {
    return JSON.parse(value) as T;
  } catch {
    return fallback;
  }
};
 
export const fromBool = (value: unknown): boolean =>
  value === 1 || value === true;
 
/**
 * Normalise a raw row: `bigint` to `number`, everything else as stored.
 *
 * NULL is deliberately preserved rather than mapped to `undefined`: PostgREST
 * serialises a NULL column as JSON `null`, so the Zod schemas are written with
 * `.nullable()` and would reject `undefined`. Columns that need JSON or boolean
 * decoding are named by the caller.
 */
export const normaliseRow = (
  row: Row,
  options: { json?: string[]; bool?: string[] } = {},
): Record<string, unknown> => {
  const json = new Set(options.json ?? []);
  const bool = new Set(options.bool ?? []);
  const out: Record<string, unknown> = {};
 
  for (const [key, value] of Object.entries(row)) {
    if (json.has(key)) {
      out[key] = fromJson(value);
      continue;
    }
    if (bool.has(key)) {
      out[key] = value === null || value === undefined ? null : fromBool(value);
      continue;
    }
    if (value === null || value === undefined) {
      out[key] = null;
      continue;
    }
    out[key] = typeof value === 'bigint' ? Number(value) : value;
  }
 
  return out;
};
 
/**
 * Build the column list, placeholders and bound values for an INSERT.
 *
 * Keys whose value is `undefined` are dropped so the column's DEFAULT applies,
 * which is how PostgREST behaves when a field is omitted from the body.
 */
export const buildInsert = (
  table: string,
  values: Record<string, InValue | undefined>,
): { sql: string; args: InValue[] } => {
  const entries = Object.entries(values).filter(
    ([, value]) => value !== undefined,
  ) as [string, InValue][];
 
  const columns = entries.map(([key]) => key);
  const placeholders = columns.map(() => '?');
 
  return {
    sql: `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`,
    args: entries.map(([, value]) => value),
  };
};
 
/**
 * Build the SET clause for an UPDATE, dropping keys the caller did not provide.
 *
 * Returns `null` when nothing is left to update, so callers can skip the write
 * rather than emitting `SET` with no assignments.
 */
export const buildUpdate = (
  table: string,
  values: Record<string, InValue | undefined>,
  where: { column: string; value: InValue },
): { sql: string; args: InValue[] } | null => {
  const entries = Object.entries(values).filter(
    ([, value]) => value !== undefined,
  ) as [string, InValue][];
 
  if (entries.length === 0) {
    return null;
  }
 
  const assignments = entries.map(([key]) => `${key} = ?`);
 
  return {
    sql: `UPDATE ${table} SET ${assignments.join(', ')} WHERE ${where.column} = ?`,
    args: [...entries.map(([, value]) => value), where.value],
  };
};
 
/**
 * Coerce a plain object into bindable column values.
 *
 * Used where a create or update object is spread into a statement: strings and
 * numbers pass through, booleans become 0/1, and anything structural is
 * stringified for the JSON TEXT column it belongs to. `undefined` survives so
 * the query builders can drop the column and let its default apply.
 */
export const asColumns = (
  values: Record<string, unknown>,
): Record<string, InValue | undefined> => {
  const out: Record<string, InValue | undefined> = {};
 
  for (const [key, value] of Object.entries(values)) {
    if (value === undefined) {
      out[key] = undefined;
    } else if (value === null) {
      out[key] = null;
    } else if (typeof value === 'boolean') {
      out[key] = value ? 1 : 0;
    } else if (
      typeof value === 'string' ||
      typeof value === 'number' ||
      typeof value === 'bigint'
    ) {
      out[key] = value;
    } else {
      out[key] = JSON.stringify(value);
    }
  }
 
  return out;
};