Skip to the content

Capsid menu

src/restore.ts

112 lines and 18 functions in DrDustinEdwards/d1-dump, at commit e15f966 on 2026-10-23. This file on GitHub.

Functions

The functions of restore.ts
FunctionLinesComplexityCoveredCRAPMutation
boundSize29 to 313100%OkCRAP 3Critical28.6%
readJson33 to 372100%OkCRAP 2Watch60%
schemaColumns41 to 46375%OkCRAP 3.1Watch75%
<anonymous:43>43 to 431100%OkCRAP 1Ok100%
checkColumns50 to 616100%WatchCRAP 6Watch76.5%
<anonymous:54>54 to 541100%OkCRAP 1Ok85.7%
<anonymous:55>55 to 551100%OkCRAP 1Ok100%
<anonymous:56>56 to 561100%OkCRAP 1Ok100%
restoreDump67 to 1241897%WatchCRAP 18Watch65.5%
<anonymous:69>69 to 711100%OkCRAP 1Ok100%
<anonymous:76>76 to 761100%OkCRAP 1Ok100%
<anonymous:77>77 to 771100%OkCRAP 1Watch71.4%
<anonymous:77>77 to 771100%OkCRAP 1No data: no mutants in this code
<anonymous:87>87 to 871100%OkCRAP 1Ok100%
flush90 to 952100%OkCRAP 2Ok85.7%
<anonymous:97>97 to 971100%OkCRAP 1Ok100%
<anonymous:99>99 to 991100%OkCRAP 1Critical0%
<anonymous:121>121 to 1211100%OkCRAP 1Ok100%

Imports

It imports src/dump.ts, src/schema.ts, src/types.ts. Imported by src/drill.ts, src/index.ts.

Source

import { COMPLETE_MARKER, SCHEMA_SIDECAR, type CompleteMarker, type SchemaSidecar } from "./dump.js";
import { quoteIdent } from "./schema.js";
import type { D1Like, D1Statement, R2Like } from "./types.js";

// D1 binds at most 100 parameters to one statement.
const D1_MAX_PARAMS = 100;
const DEFAULT_BATCH_ROWS = 100;
// A batch is also cut by the size of the values it binds, so a table of large rows is
// not sent as one huge request. A single row over this is a batch of its own.
const DEFAULT_BATCH_BYTES = 1_000_000;

export interface RestoreOptions {
  // Rows inserted per D1 batch. Each batch is one transaction.
  batchRows?: number;
  // Bound text and BLOB size after which a batch is sent, whatever its row count.
  batchBytes?: number;
}

export interface RestoreResult {
  tables: Array<{ name: string; rows: number }>;
  // External-content FTS5 tables, rebuilt from their content tables.
  rebuilt: string[];
  // Contentless FTS5 tables, created empty: the dump held no text for them.
  empty: string[];
}

// Characters of a text value, bytes of a BLOB (an array of byte values), 8 for the rest.
// An estimate for batching, not a measurement.
function boundSize(v: unknown): number {
  return typeof v === "string" || Array.isArray(v) ? v.length : 8;
}

async function readJson<T>(bucket: R2Like, key: string): Promise<T> {
  const obj = await bucket.get(key);
  if (!obj) throw new Error(`d1-dump: ${key} is missing from the dump`);
  return JSON.parse(await obj.text()) as T;
}

// The columns the restored table has, from the CREATE statement in _schema.json that was
// just run. An internal FTS5 table is dumped with its rowid too.
async function schemaColumns(db: D1Like, table: string, internalFts: boolean): Promise<string[]> {
  const { results } = await db.prepare(`PRAGMA table_info(${quoteIdent(table)})`).all<{ name: string }>();
  const columns = results.map((c) => c.name);
  if (columns.length === 0) throw new Error(`d1-dump: ${table} has no columns after its CREATE statement ran`);
  return internalFts ? ["rowid", ...columns] : columns;
}

// Every row must carry exactly the schema's columns. Comparing to the first row instead
// would pass a column missing from all of them, which restores as NULL or as a default.
function checkColumns(table: string, columns: string[], rows: Array<Record<string, unknown>>): void {
  const expected = new Set(columns);
  for (let i = 0; i < rows.length; i++) {
    const keys = Object.keys(rows[i]);
    if (keys.length === columns.length && keys.every((k) => expected.has(k))) continue;
    const lacks = columns.filter((c) => !(c in rows[i]));
    const extra = keys.filter((k) => !expected.has(k));
    throw new Error(
      `d1-dump: ${table} row ${i} does not match the schema's columns (lacks: ${lacks.join(", ") || "none"}; not in the schema: ${extra.join(", ") || "none"})`
    );
  }
}

// Rebuilds one complete dump into `db`, which should be empty. Tables and FTS5 tables
// first, then the rows (parents before children), then each external FTS5 index is
// rebuilt, then indexes, triggers and views, so no trigger fires on restored rows.
// Every table's count is checked against the marker, and a mismatch throws.
export async function restoreDump(db: D1Like, bucket: R2Like, runPrefix: string, options: RestoreOptions = {}): Promise<RestoreResult> {
  const prefix = runPrefix.endsWith("/") ? runPrefix : `${runPrefix}/`;
  const marker = await readJson<CompleteMarker>(bucket, `${prefix}${COMPLETE_MARKER}`).catch((err: Error) => {
    throw new Error(`d1-dump: ${prefix} has no readable ${COMPLETE_MARKER}, so it is not a complete dump (${err.message})`);
  });
  const { schema } = await readJson<SchemaSidecar>(bucket, `${prefix}${SCHEMA_SIDECAR}`);
  const batchRows = options.batchRows ?? DEFAULT_BATCH_ROWS;
  const batchBytes = options.batchBytes ?? DEFAULT_BATCH_BYTES;

  for (const e of schema.filter((s) => s.type === "table")) await db.prepare(e.sql).run();
  const internalFts = new Set(marker.fts.filter((f) => f.mode === "internal").map((f) => f.name));

  const restored: RestoreResult["tables"] = [];
  for (const table of marker.tables) {
    const { rows } = await readJson<{ rows: Array<Record<string, unknown>> }>(bucket, table.key);
    if (rows.length !== table.rows) throw new Error(`d1-dump: ${table.key} holds ${rows.length} rows, the marker says ${table.rows}`);
    if (rows.length > 0) {
      const columns = await schemaColumns(db, table.name, internalFts.has(table.name));
      checkColumns(table.name, columns, rows);
      if (columns.length > D1_MAX_PARAMS) throw new Error(`d1-dump: ${table.name} has ${columns.length} columns, over D1's ${D1_MAX_PARAMS} bound parameters`);
      const sql = `INSERT INTO ${quoteIdent(table.name)} (${columns.map(quoteIdent).join(", ")}) VALUES (${columns.map((_, i) => `?${i + 1}`).join(", ")})`;
      let statements: D1Statement[] = [];
      let bytes = 0;
      const flush = async () => {
        if (statements.length === 0) return;
        await db.batch([db.prepare("PRAGMA defer_foreign_keys = ON"), ...statements]);
        statements = [];
        bytes = 0;
      };
      for (const row of rows) {
        const values = columns.map((c) => row[c]);
        statements.push(db.prepare(sql).bind(...values));
        bytes += values.reduce<number>((n, v) => n + boundSize(v), 0);
        if (statements.length >= batchRows || bytes >= batchBytes) await flush();
      }
      await flush();
    }
    const { results } = await db.prepare(`SELECT COUNT(*) AS n FROM ${quoteIdent(table.name)}`).all<{ n: number }>();
    if (results[0]?.n !== table.rows) throw new Error(`d1-dump: ${table.name} restored ${results[0]?.n} rows, the dump holds ${table.rows}`);
    restored.push({ name: table.name, rows: table.rows });
  }

  const rebuilt: string[] = [];
  const empty: string[] = [];
  for (const f of marker.fts) {
    if (f.mode === "external") {
      await db.prepare(`INSERT INTO ${quoteIdent(f.name)} (${quoteIdent(f.name)}) VALUES ('rebuild')`).run();
      rebuilt.push(f.name);
    } else if (f.mode === "contentless") {
      empty.push(f.name);
    }
  }

  for (const type of ["index", "trigger", "view"] as const) {
    for (const e of schema.filter((s) => s.type === type)) await db.prepare(e.sql).run();
  }
  return { tables: restored, rebuilt, empty };
}