685 lines
25 KiB
TypeScript
685 lines
25 KiB
TypeScript
import { DatabaseSync } from "node:sqlite";
|
|
import { z } from "zod";
|
|
import { jsonParts } from "../domain/jsonSerialization.js";
|
|
import {
|
|
SQLITE_ROW_LIMIT_DEFAULT,
|
|
sqliteSchemaScope,
|
|
type InspectSqliteDatabaseInput,
|
|
type SqliteDatabase,
|
|
type SqliteValue,
|
|
} from "../domain/sqlite/sqliteDatabase.js";
|
|
import {
|
|
SQLITE_DATABASE_LIMITS,
|
|
SqliteColumnEvaluationFailure,
|
|
SqliteInspectionFailure,
|
|
sqlitePrimaryResultCode,
|
|
} from "./SqliteDatabaseLimits.js";
|
|
|
|
/** SQLite's generic SQL error, raised while compiling or evaluating an expression. */
|
|
const SQLITE_ERROR = 1;
|
|
|
|
const natural = z
|
|
.bigint()
|
|
.nonnegative()
|
|
.max(BigInt(Number.MAX_SAFE_INTEGER))
|
|
.transform(Number);
|
|
const schemaRow = z.object({
|
|
type: z.enum(["table", "index", "view", "trigger"]),
|
|
name: z.instanceof(Uint8Array),
|
|
tbl_name: z.instanceof(Uint8Array),
|
|
rootpage: natural,
|
|
sql: z.instanceof(Uint8Array).nullable().optional(),
|
|
});
|
|
const tableRow = z.object({
|
|
schema: z.string(),
|
|
name: z.string(),
|
|
type: z.enum(["table", "view", "virtual", "shadow"]),
|
|
ncol: natural,
|
|
wr: z.bigint(),
|
|
strict: z.bigint(),
|
|
});
|
|
const columnRow = z.object({
|
|
cid: natural,
|
|
name: z.instanceof(Uint8Array),
|
|
type: z.instanceof(Uint8Array),
|
|
notnull: z.bigint(),
|
|
dflt_value: z.instanceof(Uint8Array).nullable(),
|
|
pk: natural,
|
|
hidden: z
|
|
.bigint()
|
|
.transform(Number)
|
|
.pipe(z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)])),
|
|
});
|
|
const arrayRow = z.array(
|
|
z.union([
|
|
z.null(),
|
|
z.bigint(),
|
|
z.custom<number>(
|
|
(value) => typeof value === "number" && !Number.isNaN(value),
|
|
),
|
|
z.string(),
|
|
]),
|
|
);
|
|
const encodingSchema = z.enum(["UTF-8", "UTF-16le", "UTF-16be"]);
|
|
const cellByteLengths = z.array(z.bigint().nonnegative());
|
|
type DatabaseEncoding = z.output<typeof encodingSchema>;
|
|
type Inspection = Pick<
|
|
SqliteDatabase,
|
|
"engine" | "database_encoding" | "schema" | "rows" | "limitations"
|
|
>;
|
|
type SelectedTableFacts = Pick<
|
|
SqliteDatabase["schema"]["tables"][number],
|
|
"name" | "kind" | "columns"
|
|
>;
|
|
|
|
class OutputBudget {
|
|
#bytes = 1024;
|
|
retain(value: unknown): void {
|
|
// Count bounded encoded parts, including escapes, without allocating a
|
|
// document-sized string just to decide whether it fits the reply.
|
|
this.#bytes += 1;
|
|
for (const part of jsonParts(value)) {
|
|
this.#bytes += Buffer.byteLength(part);
|
|
if (this.#bytes > SQLITE_DATABASE_LIMITS.outputBytes)
|
|
throw new SqliteInspectionFailure(
|
|
"output-limit",
|
|
"Complete SQLite evidence exceeds the 16 MiB JSON reply budget; no partial success was returned.",
|
|
);
|
|
}
|
|
}
|
|
beforeDecode(row: z.output<typeof arrayRow>): void {
|
|
let minimumBytes = this.#bytes;
|
|
for (const value of row)
|
|
if (typeof value === "string") minimumBytes += Buffer.byteLength(value);
|
|
if (minimumBytes > SQLITE_DATABASE_LIMITS.outputBytes)
|
|
throw new SqliteInspectionFailure(
|
|
"output-limit",
|
|
"Selected cells exceed the 16 MiB reply budget before text/hex expansion.",
|
|
);
|
|
}
|
|
beforeProjection(lengths: readonly bigint[]): void {
|
|
const encodedBytes = lengths.reduce(
|
|
(total, length) => total + length * 2n,
|
|
BigInt(lengths.length * 64),
|
|
);
|
|
if (
|
|
encodedBytes + BigInt(this.#bytes) >
|
|
BigInt(SQLITE_DATABASE_LIMITS.outputBytes)
|
|
)
|
|
throw new SqliteInspectionFailure(
|
|
"output-limit",
|
|
"Selected row byte lengths exceed the 16 MiB reply budget before hex projection.",
|
|
);
|
|
}
|
|
}
|
|
const quoteIdentifier = (name: string): string =>
|
|
`"${name.replaceAll('"', '""')}"`;
|
|
const missingSelectedTable = (name: string): SqliteInspectionFailure =>
|
|
new SqliteInspectionFailure(
|
|
"selection",
|
|
`Selected ordinary table does not exist with that exact name: ${name}`,
|
|
);
|
|
// SQLite folds only ASCII letters when matching virtual/shadow table names.
|
|
const sqliteIdentifierKey = (name: string): string =>
|
|
name.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
|
|
const textEncoding = (encoding: DatabaseEncoding): string =>
|
|
encoding === "UTF-8"
|
|
? "utf-8"
|
|
: encoding === "UTF-16le"
|
|
? "utf-16le"
|
|
: "utf-16be";
|
|
|
|
const decodeSchemaText = (
|
|
value: Uint8Array,
|
|
encoding: DatabaseEncoding,
|
|
label: string,
|
|
): string => {
|
|
try {
|
|
return new TextDecoder(textEncoding(encoding), {
|
|
fatal: true,
|
|
ignoreBOM: true,
|
|
}).decode(value);
|
|
} catch (cause: unknown) {
|
|
throw new SqliteInspectionFailure(
|
|
"format",
|
|
`SQLite schema ${label} contains malformed ${encoding} text: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
);
|
|
}
|
|
};
|
|
|
|
const cell = (
|
|
value: z.output<typeof arrayRow>[number],
|
|
encoding: DatabaseEncoding,
|
|
): SqliteValue => {
|
|
if (value === null) return { type: "null" };
|
|
if (typeof value === "bigint")
|
|
return { type: "integer", value: String(value) };
|
|
if (typeof value === "number")
|
|
return {
|
|
type: "real",
|
|
value:
|
|
value === Infinity
|
|
? "Infinity"
|
|
: value === -Infinity
|
|
? "-Infinity"
|
|
: Object.is(value, -0)
|
|
? "-0"
|
|
: value,
|
|
};
|
|
const hex = value.slice(1).toLowerCase();
|
|
if (!/^(?:[0-9a-f]{2})*$/.test(hex))
|
|
throw new SqliteInspectionFailure(
|
|
"format",
|
|
"SQLite returned an invalid encoded cell.",
|
|
);
|
|
if (value[0] === "b") return { type: "blob", hex };
|
|
if (value[0] !== "t")
|
|
throw new SqliteInspectionFailure(
|
|
"format",
|
|
"SQLite returned an unknown encoded cell type.",
|
|
);
|
|
return {
|
|
type: "text",
|
|
value: new TextDecoder(textEncoding(encoding), { ignoreBOM: true }).decode(
|
|
Buffer.from(hex, "hex"),
|
|
),
|
|
bytes_hex: hex,
|
|
encoding,
|
|
};
|
|
};
|
|
|
|
const readColumns = (
|
|
db: DatabaseSync,
|
|
name: string,
|
|
context: {
|
|
readonly budget: OutputBudget;
|
|
readonly encoding: DatabaseEncoding;
|
|
readonly selectedColumns?: readonly string[] | undefined;
|
|
},
|
|
): SqliteDatabase["schema"]["tables"][number]["columns"] => {
|
|
const { budget, encoding, selectedColumns } = context;
|
|
const columns: SqliteDatabase["schema"]["tables"][number]["columns"] = [];
|
|
// This private read-only connection has an empty temp schema. Qualifying the
|
|
// built-in there prevents stored main tables/views from shadowing it; the
|
|
// final argument selects main's metadata. Decode bytes before names become
|
|
// SQL identifiers. Inventory-only row subsets compare exact encoded names
|
|
// first, so an unrelated malformed identifier cannot alias a requested one.
|
|
const selection =
|
|
selectedColumns === undefined
|
|
? ""
|
|
: ` WHERE CAST(name AS BLOB) IN (${selectedColumns.map(() => "CAST(? AS BLOB)").join(", ")})`;
|
|
for (const raw of db
|
|
.prepare(
|
|
`SELECT cid, CAST(name AS BLOB) AS name, CAST(type AS BLOB) AS type, "notnull", CAST(dflt_value AS BLOB) AS dflt_value, pk, hidden FROM temp.pragma_table_xinfo(?, 'main')${selection}`,
|
|
)
|
|
.iterate(name, ...(selectedColumns ?? []))) {
|
|
const value = columnRow.parse(raw);
|
|
const column = {
|
|
cid: value.cid,
|
|
name: decodeSchemaText(value.name, encoding, "column name"),
|
|
declared_type: decodeSchemaText(value.type, encoding, "column type"),
|
|
not_null: value.notnull !== 0n,
|
|
default_sql:
|
|
value.dflt_value === null
|
|
? null
|
|
: decodeSchemaText(value.dflt_value, encoding, "column default"),
|
|
primary_key_position: value.pk,
|
|
hidden: value.hidden,
|
|
};
|
|
budget.retain(column);
|
|
columns.push(column);
|
|
}
|
|
// Sort only retained facts, without copying untrusted text into native sorter records.
|
|
return columns.sort((left, right) => left.cid - right.cid);
|
|
};
|
|
|
|
const readSchema = (
|
|
db: DatabaseSync,
|
|
input: InspectSqliteDatabaseInput,
|
|
context: {
|
|
readonly budget: OutputBudget;
|
|
readonly limitations: string[];
|
|
readonly encoding: DatabaseEncoding;
|
|
},
|
|
): {
|
|
schema: SqliteDatabase["schema"];
|
|
selectedTable: SelectedTableFacts | undefined;
|
|
} => {
|
|
const { budget, limitations, encoding } = context;
|
|
const scope = sqliteSchemaScope(input);
|
|
const selectedName = scope === "selected_table" ? input.table : undefined;
|
|
if (scope === "selected_table" && selectedName === undefined)
|
|
throw new SqliteInspectionFailure(
|
|
"selection",
|
|
"Selected-table schema scope requires an exact table name.",
|
|
);
|
|
let selectedTable: SelectedTableFacts | undefined;
|
|
const metadata = new Map<string, z.output<typeof tableRow>>();
|
|
const selectedKey =
|
|
selectedName === undefined ? undefined : sqliteIdentifierKey(selectedName);
|
|
const unresolvedVirtualTables: { name: string; prefix: string }[] = [];
|
|
// Names bound eventual output only when the whole catalog is requested.
|
|
// Selected scope retains one table and at most one possible shadow parent;
|
|
// unrelated names are examined without retaining them or charging the reply.
|
|
const metadataBudget = new OutputBudget();
|
|
for (const raw of db.prepare("PRAGMA main.table_list").iterate()) {
|
|
const value = tableRow.parse(raw);
|
|
if (value.schema !== "main") continue;
|
|
if (selectedName === undefined) {
|
|
metadataBudget.retain(value.name);
|
|
metadata.set(value.name, value);
|
|
} else if (value.name === selectedName) {
|
|
metadata.set(value.name, value);
|
|
}
|
|
if (value.type === "virtual" && value.ncol === 0) {
|
|
const prefix = sqliteIdentifierKey(`${value.name}_`);
|
|
if (
|
|
selectedKey === undefined ||
|
|
(unresolvedVirtualTables.length === 0 && selectedKey.startsWith(prefix))
|
|
)
|
|
unresolvedVirtualTables.push({ name: value.name, prefix });
|
|
}
|
|
}
|
|
// A trigger stores its target as written in CREATE TRIGGER, which SQLite
|
|
// resolves by ASCII case folding; report the stored table or view name.
|
|
// A selected-table scan returns only the selected table's dependents, and
|
|
// other scopes fold stored names once, only when a spelling differs.
|
|
let foldedNames: Map<string, string> | undefined;
|
|
const triggerTable = (written: string): string => {
|
|
if (selectedName !== undefined) return selectedName;
|
|
if (metadata.has(written)) return written;
|
|
foldedNames ??= new Map(
|
|
[...metadata.keys()].map((name) => [sqliteIdentifierKey(name), name]),
|
|
);
|
|
return foldedNames.get(sqliteIdentifierKey(written)) ?? written;
|
|
};
|
|
const shadowParentOf = (
|
|
name: string,
|
|
listed: z.output<typeof tableRow>,
|
|
): (typeof unresolvedVirtualTables)[number] | undefined => {
|
|
if (listed.type !== "table") return undefined;
|
|
const identifier = sqliteIdentifierKey(name);
|
|
return unresolvedVirtualTables.find((parent) =>
|
|
identifier.startsWith(parent.prefix),
|
|
);
|
|
};
|
|
const selected =
|
|
selectedName === undefined ? undefined : metadata.get(selectedName);
|
|
if (selectedName !== undefined && selected === undefined)
|
|
throw missingSelectedTable(selectedName);
|
|
const selectedShadowParent =
|
|
selectedName === undefined || selected === undefined
|
|
? undefined
|
|
: shadowParentOf(selectedName, selected);
|
|
// NOCASE folds only ASCII letters, as SQLite does when it resolves the
|
|
// table named by an index or trigger. Fold only for an ordinary table whose
|
|
// rows can be selected; a selection refused later keeps the exact filter,
|
|
// so its failure does not depend on declarations under another spelling.
|
|
const foldDependents =
|
|
selected?.type === "table" && selectedShadowParent === undefined;
|
|
const schema: SqliteDatabase["schema"] = {
|
|
scope,
|
|
completeness: "complete",
|
|
tables: [],
|
|
indexes: [],
|
|
views: [],
|
|
triggers: [],
|
|
};
|
|
// Raw bytes preserve text without hex expansion; avoid sorter records that
|
|
// duplicate large names or definitions beyond SQLite's native record limit.
|
|
const declaration = scope === "inventory" ? "" : ", CAST(sql AS BLOB) AS sql";
|
|
const selection =
|
|
scope === "selected_table"
|
|
? ` WHERE tbl_name = ? COLLATE ${foldDependents ? "NOCASE" : "BINARY"}`
|
|
: "";
|
|
const statement = db.prepare(
|
|
`SELECT type, CAST(name AS BLOB) AS name, CAST(tbl_name AS BLOB) AS tbl_name, rootpage${declaration} FROM main.sqlite_schema${selection}`,
|
|
);
|
|
for (const raw of statement.iterate(
|
|
...(selectedName === undefined ? [] : [selectedName]),
|
|
)) {
|
|
const parsed = schemaRow.parse(raw);
|
|
const value = {
|
|
...parsed,
|
|
name: decodeSchemaText(parsed.name, encoding, "name"),
|
|
tbl_name: decodeSchemaText(parsed.tbl_name, encoding, "table"),
|
|
sql:
|
|
parsed.sql === undefined
|
|
? undefined
|
|
: parsed.sql === null
|
|
? null
|
|
: decodeSchemaText(parsed.sql, encoding, "sql"),
|
|
};
|
|
if (value.type === "table") {
|
|
const listed = metadata.get(value.name);
|
|
if (listed === undefined || listed.type === "view")
|
|
throw new SqliteInspectionFailure(
|
|
"format",
|
|
`SQLite schema/table inventory mismatch: ${value.name}`,
|
|
);
|
|
const possibleShadowParent =
|
|
value.name === selectedName
|
|
? selectedShadowParent
|
|
: shadowParentOf(value.name, listed);
|
|
const table: SqliteDatabase["schema"]["tables"][number] = {
|
|
name: value.name,
|
|
...(scope === "inventory" ? {} : { sql: value.sql }),
|
|
root_page: value.rootpage,
|
|
kind: possibleShadowParent === undefined ? listed.type : "unknown",
|
|
without_rowid: listed.wr !== 0n,
|
|
strict: listed.strict !== 0n,
|
|
column_count:
|
|
listed.type === "virtual" && listed.ncol === 0 ? null : listed.ncol,
|
|
columns_completeness: "complete",
|
|
columns: [],
|
|
};
|
|
if (possibleShadowParent !== undefined)
|
|
limitations.push(
|
|
`SQLite could not resolve virtual table ${possibleShadowParent.name}. Table ${value.name} may be its shadow table; its kind is unknown and row inspection is unavailable.`,
|
|
);
|
|
budget.retain(table);
|
|
if (scope === "inventory") table.columns_completeness = "not_requested";
|
|
if (listed.type === "virtual" && scope !== "inventory") {
|
|
table.columns_completeness = "unsupported";
|
|
limitations.push(
|
|
`Virtual table column expansion is not performed: ${value.name}. Its complete stored CREATE statement is retained.`,
|
|
);
|
|
} else if (
|
|
listed.type !== "virtual" &&
|
|
(scope !== "inventory" || input.table === value.name)
|
|
) {
|
|
const columns = readColumns(db, value.name, {
|
|
budget: scope === "inventory" ? new OutputBudget() : budget,
|
|
encoding,
|
|
selectedColumns: scope === "inventory" ? input.columns : undefined,
|
|
});
|
|
if (input.table === value.name)
|
|
selectedTable = { name: table.name, kind: table.kind, columns };
|
|
if (scope !== "inventory") table.columns = columns;
|
|
}
|
|
if (input.table === value.name && selectedTable === undefined)
|
|
selectedTable = {
|
|
name: table.name,
|
|
kind: table.kind,
|
|
columns: table.columns,
|
|
};
|
|
schema.tables.push(table);
|
|
} else if (value.type === "index") {
|
|
const index = {
|
|
name: value.name,
|
|
table: value.tbl_name,
|
|
...(scope === "inventory" ? {} : { sql: value.sql }),
|
|
root_page: value.rootpage,
|
|
};
|
|
budget.retain(index);
|
|
schema.indexes.push(index);
|
|
} else if (value.type === "view") {
|
|
const view = {
|
|
name: value.name,
|
|
...(scope === "inventory" ? {} : { sql: value.sql }),
|
|
};
|
|
budget.retain(view);
|
|
schema.views.push(view);
|
|
} else {
|
|
const trigger = {
|
|
name: value.name,
|
|
table: triggerTable(value.tbl_name),
|
|
...(scope === "inventory" ? {} : { sql: value.sql }),
|
|
};
|
|
budget.retain(trigger);
|
|
schema.triggers.push(trigger);
|
|
}
|
|
}
|
|
return { schema, selectedTable };
|
|
};
|
|
|
|
/** Byte length of one cell's projected representation, without copying its value. */
|
|
const cellByteLength = (name: string): string => {
|
|
const column = quoteIdentifier(name);
|
|
return `CASE typeof(${column}) WHEN 'text' THEN length(CAST(${column} AS BLOB)) WHEN 'blob' THEN length(${column}) ELSE 0 END`;
|
|
};
|
|
|
|
/** Ordinary and stored generated columns, read without evaluating an expression; skips undecodable names. */
|
|
const storedColumnNames = (
|
|
db: DatabaseSync,
|
|
table: string,
|
|
encoding: DatabaseEncoding,
|
|
): string[] => {
|
|
const names: string[] = [];
|
|
for (const raw of db
|
|
.prepare(
|
|
"SELECT CAST(name AS BLOB) AS name FROM temp.pragma_table_xinfo(?, 'main') WHERE hidden IN (0, 3) ORDER BY cid",
|
|
)
|
|
.iterate(table)) {
|
|
const { name } = z.object({ name: z.instanceof(Uint8Array) }).parse(raw);
|
|
try {
|
|
names.push(decodeSchemaText(name, encoding, "column name"));
|
|
} catch (cause: unknown) {
|
|
// An unrelated malformed name cannot be selected exactly, so it is not recommended.
|
|
if (!(cause instanceof SqliteInspectionFailure)) throw cause;
|
|
}
|
|
}
|
|
return names;
|
|
};
|
|
|
|
/**
|
|
* Attribute an SQL error from selected cells to the virtual generated columns
|
|
* that raise it on this connection. Each column is probed alone over the same
|
|
* sampled rows, so only columns whose probe succeeds are recommended; any
|
|
* other cause keeps its original classification.
|
|
*/
|
|
const columnEvaluationFailure = (
|
|
db: DatabaseSync,
|
|
cause: unknown,
|
|
selection: {
|
|
readonly table: SelectedTableFacts;
|
|
readonly columns: readonly string[];
|
|
readonly source: string;
|
|
readonly limit: number;
|
|
readonly encoding: DatabaseEncoding;
|
|
},
|
|
): unknown => {
|
|
if (sqlitePrimaryResultCode(cause) !== SQLITE_ERROR) return cause;
|
|
const { table, columns, source, limit, encoding } = selection;
|
|
// The aggregate evaluates exactly the sampled cells, as the length pass does.
|
|
const probe = (name: string): { readonly failure: unknown } | undefined => {
|
|
try {
|
|
db.prepare(
|
|
`SELECT count(${cellByteLength(name)}) FROM (SELECT ${quoteIdentifier(name)} FROM ${source} LIMIT ?)`,
|
|
).get(limit);
|
|
return undefined;
|
|
} catch (failure: unknown) {
|
|
return { failure };
|
|
}
|
|
};
|
|
const failures = new Map<string, unknown>();
|
|
for (const name of new Set(columns)) {
|
|
const failed = probe(name);
|
|
if (failed !== undefined) failures.set(name, failed.failure);
|
|
}
|
|
const generated = new Set(
|
|
table.columns
|
|
.filter((column) => column.hidden === 2)
|
|
.map((column) => column.name),
|
|
);
|
|
if (
|
|
![...failures].some(
|
|
([name, failure]) =>
|
|
generated.has(name) &&
|
|
sqlitePrimaryResultCode(failure) === SQLITE_ERROR,
|
|
)
|
|
)
|
|
return cause;
|
|
const remaining = columns.filter((name) => !failures.has(name));
|
|
const readableStoredColumns = (): string[] => {
|
|
try {
|
|
return storedColumnNames(db, table.name, encoding).filter(
|
|
(name) => !failures.has(name) && probe(name) === undefined,
|
|
);
|
|
} catch {
|
|
// Discovery only refines recovery; it must not replace the attributed failure.
|
|
return [];
|
|
}
|
|
};
|
|
return new SqliteColumnEvaluationFailure(
|
|
`SQLite cannot evaluate selected column(s) of table ${JSON.stringify(table.name)} on REA's read-only connection: ${[
|
|
...failures,
|
|
]
|
|
.map(
|
|
([name, failure]) =>
|
|
`${JSON.stringify(name)}: ${failure instanceof Error ? failure.message : String(failure)}`,
|
|
)
|
|
.join("; ")}`,
|
|
remaining.length > 0 ? remaining : readableStoredColumns(),
|
|
);
|
|
};
|
|
|
|
const readRows = (
|
|
db: DatabaseSync,
|
|
input: InspectSqliteDatabaseInput,
|
|
table: SelectedTableFacts | undefined,
|
|
context: {
|
|
readonly encoding: DatabaseEncoding;
|
|
readonly budget: OutputBudget;
|
|
},
|
|
): SqliteDatabase["rows"] => {
|
|
const { encoding, budget } = context;
|
|
if (input.table === undefined) return null;
|
|
if (table === undefined) throw missingSelectedTable(input.table);
|
|
if (table.kind === "unknown")
|
|
throw new SqliteInspectionFailure(
|
|
"selection",
|
|
`SQLite cannot determine whether selected table ${input.table} is a shadow table of an unresolved virtual table. Row inspection requires a confirmed ordinary table.`,
|
|
);
|
|
if (table.kind !== "table")
|
|
throw new SqliteInspectionFailure(
|
|
"selection",
|
|
`Rows from ${table.kind} table ${input.table} are outside the ordinary-table inspection profile.`,
|
|
);
|
|
const limit = input.row_limit ?? SQLITE_ROW_LIMIT_DEFAULT;
|
|
const visibleColumns = table.columns
|
|
.filter((column) => column.hidden !== 1)
|
|
.map((column) => column.name);
|
|
const columns = input.columns ?? visibleColumns;
|
|
const available = new Set(visibleColumns);
|
|
for (const name of columns)
|
|
if (!available.has(name))
|
|
throw new SqliteInspectionFailure(
|
|
"column-selection",
|
|
`Selected column does not exist with that exact name in table ${input.table}: ${name}`,
|
|
);
|
|
const projection = columns
|
|
.map((name) => {
|
|
const column = quoteIdentifier(name);
|
|
return `CASE typeof(${column}) WHEN 'text' THEN 't' || hex(CAST(${column} AS BLOB)) WHEN 'blob' THEN 'b' || hex(${column}) ELSE ${column} END`;
|
|
})
|
|
.join(", ");
|
|
const source = `main.${quoteIdentifier(input.table)} NOT INDEXED`;
|
|
const rows: NonNullable<SqliteDatabase["rows"]> = {
|
|
table: input.table,
|
|
columns,
|
|
values: [],
|
|
row_limit: limit,
|
|
returned_rows: 0,
|
|
truncated: false,
|
|
order: "unspecified",
|
|
};
|
|
// SQLite compiles and evaluates only selected cells, so an SQL error here is
|
|
// attributed to the selected generated expressions rather than the file.
|
|
try {
|
|
const lengths = db.prepare(
|
|
`SELECT ${columns.map(cellByteLength).join(", ")} FROM ${source} LIMIT ?`,
|
|
);
|
|
lengths.setReturnArrays(true);
|
|
for (const raw of lengths.iterate(limit))
|
|
budget.beforeProjection(cellByteLengths.parse(raw));
|
|
const statement = db.prepare(`SELECT ${projection} FROM ${source} LIMIT ?`);
|
|
statement.setReturnArrays(true);
|
|
for (const raw of statement.iterate(limit)) {
|
|
const rawRow = arrayRow.parse(raw);
|
|
// Hex expansion already belongs to the retained-output budget, before decoding/copying cells.
|
|
budget.beforeDecode(rawRow);
|
|
const values = rawRow.map((value) => cell(value, encoding));
|
|
budget.retain(values);
|
|
rows.values.push(values);
|
|
}
|
|
} catch (cause: unknown) {
|
|
throw columnEvaluationFailure(db, cause, {
|
|
table,
|
|
columns,
|
|
source,
|
|
limit,
|
|
encoding,
|
|
});
|
|
}
|
|
rows.returned_rows = rows.values.length;
|
|
rows.truncated =
|
|
db.prepare(`SELECT 1 FROM ${source} LIMIT 1 OFFSET ?`).get(limit) !==
|
|
undefined;
|
|
return rows;
|
|
};
|
|
|
|
/** Query only a provider-owned snapshot; no caller SQL, extensions, views or virtual rows. */
|
|
export const inspectSqliteDatabaseSnapshot = (
|
|
snapshotPath: string,
|
|
input: InspectSqliteDatabaseInput,
|
|
): Inspection => {
|
|
const db = new DatabaseSync(snapshotPath, {
|
|
readOnly: true,
|
|
allowExtension: false,
|
|
readBigInts: true,
|
|
enableDoubleQuotedStringLiterals: false,
|
|
timeout: 0,
|
|
limits: {
|
|
length: SQLITE_DATABASE_LIMITS.outputBytes,
|
|
sqlLength: SQLITE_DATABASE_LIMITS.outputBytes,
|
|
},
|
|
});
|
|
try {
|
|
const limits: unknown = db.limits;
|
|
const supported = z
|
|
.object({
|
|
length: z.int().positive().max(SQLITE_DATABASE_LIMITS.outputBytes),
|
|
sqlLength: z.int().positive().max(SQLITE_DATABASE_LIMITS.outputBytes),
|
|
})
|
|
.safeParse(limits);
|
|
if (!supported.success)
|
|
throw new SqliteInspectionFailure(
|
|
"unavailable",
|
|
"SQLite inspection requires the native DatabaseSync.limits API to bound untrusted values and SQL. Use Node.js 24.15 or newer in the supported 24.x line, or a compatible Node.js 26+ runtime.",
|
|
);
|
|
db.exec(
|
|
"PRAGMA trusted_schema=OFF; PRAGMA query_only=ON; PRAGMA mmap_size=0; PRAGMA cell_size_check=ON; BEGIN;",
|
|
);
|
|
const version = z
|
|
.object({ version: z.string() })
|
|
.parse(db.prepare("SELECT sqlite_version() AS version").get()).version;
|
|
const encoding = z
|
|
.object({ encoding: encodingSchema })
|
|
.parse(db.prepare("PRAGMA encoding").get()).encoding;
|
|
const limitations = [
|
|
"Inspects an offline stable file set. Filesystem identity checks detect observed acquisition changes; they do not establish an atomic snapshot of an actively changing database. Supply an application-created backup or quiescent database/WAL pair.",
|
|
"The source database and WAL are never opened by SQLite or modified. A private copy may acquire its own WAL-index sidecar and is removed after inspection.",
|
|
"SQLite reports current schema and selected committed rows from its valid database/WAL state. Deleted records, uncommitted or invalid WAL tails, encrypted databases, forensic recovery, and full integrity verification are outside this profile.",
|
|
"Stored CREATE statements preserve index/view/trigger definitions. Views, triggers, and virtual/shadow table rows are not executed; virtual-table column expansion is unsupported.",
|
|
"Selected rows have unspecified order and are a bounded sample. Text display may replace invalid sequences; bytes_hex and encoding preserve SQLite's text byte representation, not original on-disk page offsets.",
|
|
"Resource boundaries: at most two concurrent inspections per provider; combined input 256 MiB per inspection, complete worker JSON 16 MiB, native SQLite value/record and SQL statement lengths 16 MiB, owned-worker JavaScript heap 256 MiB. The parent applies a separate supervised worker deadline, reported in worker_timeout_ms. These are distinct from total resident memory; an overall native SQLite heap cap is not claimed. Failures return no partial success.",
|
|
];
|
|
const budget = new OutputBudget();
|
|
const { schema, selectedTable } = readSchema(db, input, {
|
|
budget,
|
|
limitations,
|
|
encoding,
|
|
});
|
|
return {
|
|
engine: { name: "SQLite", version },
|
|
database_encoding: encoding,
|
|
schema,
|
|
rows: readRows(db, input, selectedTable, { encoding, budget }),
|
|
limitations,
|
|
};
|
|
} finally {
|
|
db.close();
|
|
}
|
|
};
|