mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
4382 lines
177 KiB
TypeScript
4382 lines
177 KiB
TypeScript
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
|
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
|
|
import { deindent, stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
|
|
import ELK from "elkjs/lib/elk.bundled.js";
|
|
import http from "node:http";
|
|
import { performance } from "node:perf_hooks";
|
|
import { exampleFungibleLedgerSchema } from "../src/lib/bulldozer/db/example-schema";
|
|
import { createBulldozerExecutionContext, toExecutableSqlTransaction, toQueryableSqlQuery } from "../src/lib/bulldozer/db/index";
|
|
import { quoteSqlJsonbLiteral, quoteSqlStringLiteral } from "../src/lib/bulldozer/db/utilities";
|
|
import { createPaymentsSchema } from "../src/lib/payments/schema/index";
|
|
import { globalPrismaClient, retryTransaction } from "../src/prisma-client";
|
|
|
|
type JsonPrimitive = string | number | boolean | null;
|
|
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
|
type SqlExpression<T> = { type: "expression", sql: string };
|
|
type SqlStatement = { type: "statement", sql: string, outputName?: string, requiresSequentialExecution?: boolean };
|
|
type SqlQuery = { type: "query", sql: string, toStatement(outputName?: string): SqlStatement };
|
|
type AutoExplainMetadata = {
|
|
enabled: boolean,
|
|
setupError: string | null,
|
|
logReadError: string | null,
|
|
logPath: string | null,
|
|
logReadBytes: number,
|
|
markerFound: boolean,
|
|
parsedEntryCount: number,
|
|
parseErrorCount: number,
|
|
rawLogExcerpt: string | null,
|
|
};
|
|
type StatementExecutionMetrics = {
|
|
durationMs: number,
|
|
statementCount: number,
|
|
logicalStatementCount: number,
|
|
executableStatementCount: number,
|
|
sequentialStatementCount: number,
|
|
uniqueTableReferenceCount: number,
|
|
sqlScriptLength: number,
|
|
sqlScript: string,
|
|
firstStatementPreviews: Array<{ index: number, outputName: string | null, sqlPreview: string }>,
|
|
lastStatementPreviews: Array<{ index: number, outputName: string | null, sqlPreview: string }>,
|
|
topTableReferences: Array<{ tableId: string, statementReferences: number }>,
|
|
timingBreakdown: {
|
|
buildSqlScriptMs: number,
|
|
buildInstrumentationMs: number,
|
|
preExecutionSnapshotMs: number,
|
|
executePrimaryMs: number,
|
|
executeFallbackMs: number,
|
|
postExecutionSnapshotMs: number,
|
|
autoExplainReadParseMs: number,
|
|
metricsAssemblyMs: number,
|
|
preparationMs: number,
|
|
statementWallMsTotal: number,
|
|
postProcessingMs: number,
|
|
uncategorizedMs: number,
|
|
totalPlanningMs: number,
|
|
totalExecutionMs: number,
|
|
totalAutoExplainDurationMs: number,
|
|
capturedNonPlannerExecutionMs: number,
|
|
uncapturedExecutionMs: number,
|
|
explainedStatementCount: number,
|
|
nestedAutoExplainEntryCount: number,
|
|
capturedExecutableStatementCount: number,
|
|
notExplainedStatementCount: number,
|
|
},
|
|
slowestStatements: Array<{
|
|
index: number,
|
|
kind: string,
|
|
outputName: string | null,
|
|
wallMs: number,
|
|
planningMs: number | null,
|
|
executionMs: number | null,
|
|
rootNodeType: string | null,
|
|
actualRows: number | null,
|
|
sharedHitBlocks: number | null,
|
|
sharedReadBlocks: number | null,
|
|
tempWrittenBlocks: number | null,
|
|
walBytes: number | null,
|
|
sqlPreview: string,
|
|
rowChangeDiagnosticTableId: string | null,
|
|
rowChangeObservedRows: number | null,
|
|
rowChangeDiagnosticStatementKey: string | null,
|
|
}>,
|
|
rowChangeDiagnostics: Array<{
|
|
tableId: string,
|
|
changedRows: number | null,
|
|
capturedStatementCount: number,
|
|
expectedStatementCount: number,
|
|
}>,
|
|
autoExplain: AutoExplainMetadata,
|
|
};
|
|
|
|
type StudioTable = {
|
|
tableId: unknown,
|
|
inputTables?: StudioTable[],
|
|
debugArgs?: Record<string, unknown>,
|
|
listGroups(executionContext: ReturnType<typeof createBulldozerExecutionContext>, options: { start: SqlExpression<unknown> | "start", end: SqlExpression<unknown> | "end", startInclusive: boolean, endInclusive: boolean }): SqlQuery,
|
|
listRowsInGroup(executionContext: ReturnType<typeof createBulldozerExecutionContext>, options: { groupKey?: SqlExpression<unknown>, start: SqlExpression<unknown> | "start", end: SqlExpression<unknown> | "end", startInclusive: boolean, endInclusive: boolean }): SqlQuery,
|
|
init(executionContext: ReturnType<typeof createBulldozerExecutionContext>): SqlStatement[],
|
|
delete(executionContext: ReturnType<typeof createBulldozerExecutionContext>): SqlStatement[],
|
|
isInitialized(executionContext: ReturnType<typeof createBulldozerExecutionContext>): SqlExpression<boolean>,
|
|
registerRowChangeTrigger(trigger: (executionContext: ReturnType<typeof createBulldozerExecutionContext>, changesTable: SqlExpression<{ __brand: "$SQL_Table" }>) => SqlStatement[]): { deregister: () => void },
|
|
};
|
|
|
|
type StudioStoredTable = StudioTable & {
|
|
setRow(executionContext: ReturnType<typeof createBulldozerExecutionContext>, rowIdentifier: string, rowData: SqlExpression<Record<string, JsonValue>>): SqlStatement[],
|
|
deleteRow(executionContext: ReturnType<typeof createBulldozerExecutionContext>, rowIdentifier: string): SqlStatement[],
|
|
};
|
|
|
|
type StudioTableRecord = {
|
|
id: string,
|
|
name: string,
|
|
table: StudioTable,
|
|
};
|
|
|
|
const STUDIO_PORT = Number(`${getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81")}40`);
|
|
const STUDIO_HOST = "127.0.0.1";
|
|
const BULLDOZER_LOCK_ID = 7857391;
|
|
const STUDIO_INSTANCE_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
const STUDIO_AUTH_TOKEN = getEnvVariable("STACK_BULLDOZER_STUDIO_AUTH_TOKEN", STUDIO_INSTANCE_ID);
|
|
const STUDIO_AUTH_HEADER = "x-stack-bulldozer-studio-token";
|
|
const MAX_REQUEST_BODY_BYTES = 1024 * 1024;
|
|
const GRAPH_NODE_WIDTH = 260;
|
|
const GRAPH_NODE_HEIGHT = 126;
|
|
const GRAPH_LEVEL_GAP_Y = 230;
|
|
const GRAPH_COLUMN_GAP_X = 320;
|
|
const GRAPH_SCENE_MARGIN = 40;
|
|
const STATEMENT_SQL_PREVIEW_CHARS = 260;
|
|
const SLOW_STATEMENT_LIMIT = 20;
|
|
const AUTO_EXPLAIN_LOG_SAMPLE_BYTES = 8 * 1024 * 1024;
|
|
const AUTO_EXPLAIN_MAX_LOG_SAMPLE_BYTES = 24 * 1024 * 1024;
|
|
const AUTO_EXPLAIN_LOG_EXCERPT_CHARS = 12_000;
|
|
const AUTO_EXPLAIN_CAPTURE_RETRY_ATTEMPTS = 4;
|
|
const AUTO_EXPLAIN_CAPTURE_RETRY_DELAY_MS = 120;
|
|
const ROW_CHANGE_DIAGNOSTIC_COLUMN_NAME = "__row_change_table_id";
|
|
const elk = new ELK();
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function isStudioTable(value: unknown): value is StudioTable {
|
|
if (!isRecord(value)) return false;
|
|
return typeof Reflect.get(value, "listGroups") === "function"
|
|
&& typeof Reflect.get(value, "listRowsInGroup") === "function"
|
|
&& typeof Reflect.get(value, "init") === "function"
|
|
&& typeof Reflect.get(value, "delete") === "function"
|
|
&& typeof Reflect.get(value, "isInitialized") === "function"
|
|
&& typeof Reflect.get(value, "registerRowChangeTrigger") === "function";
|
|
}
|
|
|
|
function isStudioStoredTable(value: StudioTable): value is StudioStoredTable {
|
|
return typeof Reflect.get(value, "setRow") === "function"
|
|
&& typeof Reflect.get(value, "deleteRow") === "function";
|
|
}
|
|
|
|
function requireRecord(value: unknown, errorMessage: string): Record<string, unknown> {
|
|
if (!isRecord(value)) throw new StackAssertionError(errorMessage);
|
|
return value;
|
|
}
|
|
|
|
function requireString(value: unknown, errorMessage: string): string {
|
|
if (typeof value !== "string") throw new StackAssertionError(errorMessage);
|
|
return value;
|
|
}
|
|
|
|
function requireStringArray(value: unknown, errorMessage: string): string[] {
|
|
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
|
|
throw new StackAssertionError(errorMessage);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function isJsonValue(value: unknown): value is JsonValue {
|
|
if (
|
|
value === null
|
|
|| typeof value === "string"
|
|
|| typeof value === "number"
|
|
|| typeof value === "boolean"
|
|
) {
|
|
return true;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.every((item) => isJsonValue(item));
|
|
}
|
|
if (isRecord(value)) {
|
|
return Object.values(value).every((item) => isJsonValue(item));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function requireJsonValue(value: unknown, errorMessage: string): JsonValue {
|
|
if (!isJsonValue(value)) {
|
|
throw new StackAssertionError(errorMessage);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function keyPathSqlLiteral(pathSegments: string[]): string {
|
|
if (pathSegments.length === 0) return "ARRAY[]::jsonb[]";
|
|
return `ARRAY[${pathSegments.map((segment) => quoteSqlJsonbLiteral(segment).sql).join(", ")}]::jsonb[]`;
|
|
}
|
|
|
|
type AutoExplainParseResult = {
|
|
parsedEntries: StatementExecutionMetrics["slowestStatements"],
|
|
parseErrorCount: number,
|
|
};
|
|
|
|
type PostgresLogSnapshot = { path: string, size: number };
|
|
|
|
function normalizeErrorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
function readFiniteNumber(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
if (typeof value === "bigint") {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function toSqlPreview(sql: string): string {
|
|
return sql.length <= STATEMENT_SQL_PREVIEW_CHARS
|
|
? sql
|
|
: `${sql.slice(0, STATEMENT_SQL_PREVIEW_CHARS)}...`;
|
|
}
|
|
|
|
function statementKindFromSql(sql: string): string {
|
|
const withoutLeadingComments = sql.replace(/^(\s*--[^\n]*\n)+/g, "").trimStart();
|
|
const match = withoutLeadingComments.match(/^[A-Za-z]+/);
|
|
return (match?.[0] ?? "UNKNOWN").toUpperCase();
|
|
}
|
|
|
|
function toNonNegativeInteger(value: unknown): number | null {
|
|
const parsed = readFiniteNumber(value);
|
|
if (parsed == null || parsed < 0) return null;
|
|
return Math.floor(parsed);
|
|
}
|
|
|
|
function maxActualRowsInPlanNode(planNode: unknown): number | null {
|
|
if (!isRecord(planNode)) return null;
|
|
let maxRows = readFiniteNumber(planNode["Actual Rows"]);
|
|
const childPlans = Array.isArray(planNode.Plans)
|
|
? planNode.Plans
|
|
: [];
|
|
for (const childPlan of childPlans) {
|
|
const childRows = maxActualRowsInPlanNode(childPlan);
|
|
if (childRows == null) continue;
|
|
if (maxRows == null || childRows > maxRows) {
|
|
maxRows = childRows;
|
|
}
|
|
}
|
|
return maxRows;
|
|
}
|
|
|
|
function parseRowChangeDiagnosticTableId(sqlText: string): string | null {
|
|
const match = sqlText.match(new RegExp(`SELECT\\s+'((?:[^']|'')*)'::text\\s+AS\\s+"${ROW_CHANGE_DIAGNOSTIC_COLUMN_NAME}"`, "i"));
|
|
if (match == null) return null;
|
|
return match[1].replaceAll("''", "'");
|
|
}
|
|
|
|
function canonicalizeDiagnosticTableId(tableId: string): string {
|
|
if (!tableId.startsWith("{")) return tableId;
|
|
try {
|
|
const parsed = JSON.parse(tableId) as unknown;
|
|
if (!isRecord(parsed)) return tableId;
|
|
const parent = Reflect.get(parsed, "parent");
|
|
if (typeof parent === "string") return parent;
|
|
if (isRecord(parent)) return canonicalizeDiagnosticTableId(JSON.stringify(parent));
|
|
return tableId;
|
|
} catch {
|
|
return tableId;
|
|
}
|
|
}
|
|
|
|
async function sleepMs(durationMs: number): Promise<void> {
|
|
await new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
}
|
|
|
|
async function getCurrentPostgresLogSnapshot(): Promise<{ snapshot: PostgresLogSnapshot | null, error: string | null }> {
|
|
try {
|
|
const logPathRows = await globalPrismaClient.$queryRawUnsafe<Array<Record<string, unknown>>>(`SELECT pg_current_logfile() AS "path"`);
|
|
const logPath = typeof logPathRows[0]?.path === "string" ? logPathRows[0].path : null;
|
|
if (logPath == null || logPath.trim() === "") {
|
|
return { snapshot: null, error: "pg_current_logfile returned no active log file" };
|
|
}
|
|
const logPathLiteral = quoteSqlStringLiteral(logPath).sql;
|
|
const logSizeRows = await globalPrismaClient.$queryRawUnsafe<Array<Record<string, unknown>>>(`SELECT (pg_stat_file(${logPathLiteral})).size AS "size"`);
|
|
const logSize = toNonNegativeInteger(logSizeRows[0]?.size);
|
|
if (logSize == null) {
|
|
return { snapshot: null, error: "Unable to read PostgreSQL log file size" };
|
|
}
|
|
return { snapshot: { path: logPath, size: logSize }, error: null };
|
|
} catch (error) {
|
|
return { snapshot: null, error: normalizeErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
async function readPostgresLogChunk(path: string, offset: number, length: number): Promise<{ content: string | null, error: string | null }> {
|
|
try {
|
|
const pathLiteral = quoteSqlStringLiteral(path).sql;
|
|
const safeOffset = Math.max(0, Math.floor(offset));
|
|
const safeLength = Math.max(0, Math.floor(length));
|
|
const rows = await globalPrismaClient.$queryRawUnsafe<Array<Record<string, unknown>>>(`SELECT pg_read_file(${pathLiteral}, ${safeOffset}, ${safeLength}) AS "content"`);
|
|
const content = typeof rows[0]?.content === "string" ? rows[0].content : null;
|
|
if (content == null) {
|
|
return { content: null, error: "pg_read_file returned no content" };
|
|
}
|
|
return { content, error: null };
|
|
} catch (error) {
|
|
return { content: null, error: normalizeErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
function extractTextBetweenMarkers(content: string, startMarker: string, endMarker: string): { text: string, markerFound: boolean, startIndex: number, endIndex: number } {
|
|
const startIndex = content.indexOf(startMarker);
|
|
if (startIndex < 0) {
|
|
return { text: content, markerFound: false, startIndex: -1, endIndex: -1 };
|
|
}
|
|
const endIndex = content.indexOf(endMarker, startIndex + startMarker.length);
|
|
if (endIndex < 0) {
|
|
return { text: content.slice(startIndex), markerFound: false, startIndex, endIndex: -1 };
|
|
}
|
|
return {
|
|
text: content.slice(startIndex, endIndex + endMarker.length),
|
|
markerFound: true,
|
|
startIndex,
|
|
endIndex,
|
|
};
|
|
}
|
|
|
|
function extractBalancedJsonValue(input: string, startIndex: number): { jsonText: string, endIndex: number } | null {
|
|
const opener = input[startIndex];
|
|
if (opener !== "{" && opener !== "[") return null;
|
|
const closer = opener === "{" ? "}" : "]";
|
|
let depth = 0;
|
|
let inString = false;
|
|
let isEscaped = false;
|
|
for (let index = startIndex; index < input.length; index++) {
|
|
const current = input[index];
|
|
if (inString) {
|
|
if (isEscaped) {
|
|
isEscaped = false;
|
|
} else if (current === "\\") {
|
|
isEscaped = true;
|
|
} else if (current === "\"") {
|
|
inString = false;
|
|
}
|
|
continue;
|
|
}
|
|
if (current === "\"") {
|
|
inString = true;
|
|
continue;
|
|
}
|
|
if (current === opener) {
|
|
depth += 1;
|
|
continue;
|
|
}
|
|
if (current === closer) {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
return {
|
|
jsonText: input.slice(startIndex, index + 1),
|
|
endIndex: index + 1,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseAutoExplainEntries(logChunk: string): AutoExplainParseResult {
|
|
const parsedEntries: StatementExecutionMetrics["slowestStatements"] = [];
|
|
let parseErrorCount = 0;
|
|
let searchIndex = 0;
|
|
while (searchIndex < logChunk.length) {
|
|
const planIndex = logChunk.indexOf("plan:", searchIndex);
|
|
if (planIndex < 0) break;
|
|
const durationFragment = logChunk.slice(Math.max(0, planIndex - 180), planIndex);
|
|
const durationMatch = durationFragment.match(/duration:\s*([0-9]+(?:\.[0-9]+)?)\s*ms/i);
|
|
const jsonStart = logChunk.slice(planIndex).search(/[\[{]/);
|
|
if (jsonStart < 0) {
|
|
searchIndex = planIndex + 5;
|
|
continue;
|
|
}
|
|
const jsonStartIndex = planIndex + jsonStart;
|
|
const extracted = extractBalancedJsonValue(logChunk, jsonStartIndex);
|
|
if (extracted == null) {
|
|
parseErrorCount += 1;
|
|
searchIndex = jsonStartIndex + 1;
|
|
continue;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(extracted.jsonText) as unknown;
|
|
const explainEntry = isRecord(parsed)
|
|
? parsed
|
|
: (Array.isArray(parsed) ? parsed.find((entry) => isRecord(entry) && isRecord(entry.Plan)) as Record<string, unknown> | undefined : undefined) ?? null;
|
|
if (explainEntry == null) {
|
|
searchIndex = extracted.endIndex;
|
|
continue;
|
|
}
|
|
const plan = isRecord(explainEntry.Plan) ? explainEntry.Plan : null;
|
|
const queryText = typeof explainEntry["Query Text"] === "string"
|
|
? explainEntry["Query Text"]
|
|
: "";
|
|
const rawRowChangeDiagnosticTableId = parseRowChangeDiagnosticTableId(queryText);
|
|
const rowChangeDiagnosticTableId = rawRowChangeDiagnosticTableId == null
|
|
? null
|
|
: canonicalizeDiagnosticTableId(rawRowChangeDiagnosticTableId);
|
|
const rowChangeDiagnosticStatementKeyMatch = queryText.match(/SELECT\s+'((?:[^']|'')*row_change_diag_[^']*)'\s*,\s*to_jsonb\("__statement_output"\)/i);
|
|
const rowChangeDiagnosticStatementKey = rowChangeDiagnosticStatementKeyMatch == null
|
|
? null
|
|
: rowChangeDiagnosticStatementKeyMatch[1].replaceAll("''", "'");
|
|
const rowChangeObservedRows = rowChangeDiagnosticTableId == null
|
|
? null
|
|
: maxActualRowsInPlanNode(plan);
|
|
let executionMs = readFiniteNumber(explainEntry["Execution Time"]);
|
|
let planningMs = readFiniteNumber(explainEntry["Planning Time"]);
|
|
const durationMs = durationMatch == null ? null : Number(durationMatch[1]);
|
|
const actualTotalTimeMs = readFiniteNumber(plan?.["Actual Total Time"]);
|
|
if (executionMs == null && actualTotalTimeMs != null) {
|
|
executionMs = actualTotalTimeMs;
|
|
}
|
|
if (planningMs == null && durationMs != null && executionMs != null) {
|
|
planningMs = Math.max(0, Number((durationMs - executionMs).toFixed(3)));
|
|
}
|
|
parsedEntries.push({
|
|
index: parsedEntries.length,
|
|
kind: statementKindFromSql(queryText),
|
|
outputName: null,
|
|
wallMs: Number((durationMs ?? executionMs ?? planningMs ?? 0).toFixed(3)),
|
|
planningMs,
|
|
executionMs,
|
|
rootNodeType: typeof plan?.["Node Type"] === "string" ? plan["Node Type"] : null,
|
|
actualRows: readFiniteNumber(plan?.["Actual Rows"]),
|
|
sharedHitBlocks: readFiniteNumber(plan?.["Shared Hit Blocks"]),
|
|
sharedReadBlocks: readFiniteNumber(plan?.["Shared Read Blocks"]),
|
|
tempWrittenBlocks: readFiniteNumber(plan?.["Temp Written Blocks"]),
|
|
walBytes: readFiniteNumber(plan?.["WAL Bytes"]),
|
|
sqlPreview: toSqlPreview(queryText),
|
|
rowChangeDiagnosticTableId,
|
|
rowChangeObservedRows,
|
|
rowChangeDiagnosticStatementKey,
|
|
});
|
|
} catch {
|
|
parseErrorCount += 1;
|
|
}
|
|
searchIndex = extracted.endIndex;
|
|
}
|
|
return { parsedEntries, parseErrorCount };
|
|
}
|
|
|
|
function tableIdToString(tableId: unknown): string {
|
|
if (typeof tableId === "string") return tableId;
|
|
return JSON.stringify(tableId);
|
|
}
|
|
|
|
type CategoryRecord = { id: string, label: string, color: string, tableIds: string[] };
|
|
|
|
function createTableRegistry(schema: Record<string, unknown>): {
|
|
tables: StudioTableRecord[],
|
|
tableById: Map<string, StudioTableRecord>,
|
|
idByTable: Map<StudioTable, string>,
|
|
categories: CategoryRecord[],
|
|
} {
|
|
const tables: StudioTableRecord[] = [];
|
|
const idByTable = new Map<StudioTable, string>();
|
|
const seen = new Set<StudioTable>();
|
|
|
|
function addTable(name: string, value: unknown) {
|
|
if (!isStudioTable(value)) return;
|
|
if (seen.has(value)) return;
|
|
seen.add(value);
|
|
const record: StudioTableRecord = { id: name, name, table: value };
|
|
tables.push(record);
|
|
idByTable.set(value, name);
|
|
}
|
|
|
|
function walk(obj: Record<string, unknown>, prefix: string) {
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
if (key === "_categories") continue;
|
|
if (isStudioTable(value)) {
|
|
addTable(key, value);
|
|
} else if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
if (isStudioTable(item)) {
|
|
const tableId = typeof item.tableId === "string" ? item.tableId : `${prefix}${key}`;
|
|
addTable(tableId, item);
|
|
}
|
|
}
|
|
} else if (isRecord(value) && !seen.has(value as any)) {
|
|
walk(value as Record<string, unknown>, `${prefix}${key}.`);
|
|
}
|
|
}
|
|
}
|
|
walk(schema, "");
|
|
|
|
if (tables.length === 0) {
|
|
throw new StackAssertionError("No studio-compatible tables found in schema object.");
|
|
}
|
|
|
|
const categories: CategoryRecord[] = [];
|
|
const rawCategories = schema._categories;
|
|
if (isRecord(rawCategories)) {
|
|
for (const [catId, catValue] of Object.entries(rawCategories)) {
|
|
if (!isRecord(catValue)) continue;
|
|
const label = typeof catValue.label === "string" ? catValue.label : catId;
|
|
const color = typeof catValue.color === "string" ? catValue.color : "rgba(128,128,128,0.08)";
|
|
const catTables = Array.isArray(catValue.tables) ? catValue.tables : [];
|
|
const tableIds = catTables
|
|
.filter((t): t is StudioTable => isStudioTable(t))
|
|
.map((t) => idByTable.get(t))
|
|
.filter((id): id is string => id != null);
|
|
if (tableIds.length > 0) {
|
|
categories.push({ id: catId, label, color, tableIds });
|
|
}
|
|
}
|
|
}
|
|
|
|
const tableById = new Map(tables.map((table) => [table.id, table]));
|
|
return { tables, tableById, idByTable, categories };
|
|
}
|
|
|
|
const AVAILABLE_SCHEMAS: Record<string, () => Record<string, unknown>> = {
|
|
"example": () => exampleFungibleLedgerSchema,
|
|
"payments": () => createPaymentsSchema(),
|
|
};
|
|
let currentSchemaName = getEnvVariable("STACK_BULLDOZER_STUDIO_SCHEMA", "example");
|
|
let registry = createTableRegistry(
|
|
(AVAILABLE_SCHEMAS[currentSchemaName] ?? AVAILABLE_SCHEMAS["example"])()
|
|
);
|
|
function switchSchema(name: string): void {
|
|
const factory = Reflect.get(AVAILABLE_SCHEMAS, name);
|
|
if (typeof factory !== "function") {
|
|
throw new StackAssertionError(`Unknown schema "${name}". Available: ${Object.keys(AVAILABLE_SCHEMAS).join(", ")}`);
|
|
}
|
|
currentSchemaName = name;
|
|
registry = createTableRegistry(factory());
|
|
}
|
|
|
|
async function executeStatements(statements: SqlStatement[]): Promise<StatementExecutionMetrics> {
|
|
const executionContext = createBulldozerExecutionContext();
|
|
const startedAt = performance.now();
|
|
const buildSqlScriptStartedAt = performance.now();
|
|
const sqlScript = toExecutableSqlTransaction(executionContext, statements);
|
|
const buildSqlScriptMsRaw = performance.now() - buildSqlScriptStartedAt;
|
|
const autoExplainStartMarker = `bulldozer_studio_auto_explain_start:${STUDIO_INSTANCE_ID}:${Math.random().toString(36).slice(2, 10)}`;
|
|
const autoExplainEndMarker = `bulldozer_studio_auto_explain_end:${STUDIO_INSTANCE_ID}:${Math.random().toString(36).slice(2, 10)}`;
|
|
const autoExplainSetupSql = deindent`
|
|
LOAD 'auto_explain';
|
|
SET LOCAL auto_explain.log_min_duration = 0;
|
|
SET LOCAL auto_explain.log_analyze = on;
|
|
SET LOCAL auto_explain.log_nested_statements = on;
|
|
SET LOCAL auto_explain.log_buffers = on;
|
|
SET LOCAL auto_explain.log_wal = on;
|
|
SET LOCAL auto_explain.log_timing = on;
|
|
SET LOCAL auto_explain.log_settings = on;
|
|
SET LOCAL auto_explain.log_format = 'json';
|
|
SET LOCAL auto_explain.log_level = 'log';
|
|
`;
|
|
const buildInstrumentationStartedAt = performance.now();
|
|
const instrumentedSqlScript = sqlScript.includes("BEGIN;")
|
|
? sqlScript.replace("BEGIN;", `BEGIN;\n${autoExplainSetupSql}`)
|
|
: sqlScript;
|
|
const wrappedInstrumentedSqlScript = deindent`
|
|
DO $$ BEGIN RAISE LOG ${quoteSqlStringLiteral(autoExplainStartMarker).sql}; END $$;
|
|
${instrumentedSqlScript}
|
|
DO $$ BEGIN RAISE LOG ${quoteSqlStringLiteral(autoExplainEndMarker).sql}; END $$;
|
|
`;
|
|
const buildInstrumentationMsRaw = performance.now() - buildInstrumentationStartedAt;
|
|
const preExecutionSnapshotStartedAt = performance.now();
|
|
const logSnapshotBefore = await getCurrentPostgresLogSnapshot();
|
|
const preExecutionSnapshotMsRaw = performance.now() - preExecutionSnapshotStartedAt;
|
|
const executionStartedAt = performance.now();
|
|
let autoExplainSetupError: string | null = null;
|
|
let executePrimaryMsRaw = 0;
|
|
let executeFallbackMsRaw = 0;
|
|
const executePrimaryStartedAt = performance.now();
|
|
try {
|
|
await globalPrismaClient.$executeRawUnsafe(wrappedInstrumentedSqlScript);
|
|
executePrimaryMsRaw = performance.now() - executePrimaryStartedAt;
|
|
} catch (error) {
|
|
executePrimaryMsRaw = performance.now() - executePrimaryStartedAt;
|
|
const message = normalizeErrorMessage(error);
|
|
const autoExplainFailure = /auto_explain|unrecognized configuration parameter|could not access file|permission denied/i.test(message);
|
|
if (!autoExplainFailure) {
|
|
throw error;
|
|
}
|
|
autoExplainSetupError = message;
|
|
const executeFallbackStartedAt = performance.now();
|
|
await globalPrismaClient.$executeRawUnsafe(sqlScript);
|
|
executeFallbackMsRaw = performance.now() - executeFallbackStartedAt;
|
|
}
|
|
const executionFinishedAt = performance.now();
|
|
const statementWallMsTotalRaw = executionFinishedAt - executionStartedAt;
|
|
const statementWallMsTotal = Number(statementWallMsTotalRaw.toFixed(1));
|
|
const postProcessingStartedAt = executionFinishedAt;
|
|
const postExecutionSnapshotStartedAt = performance.now();
|
|
const logSnapshotAfter = await getCurrentPostgresLogSnapshot();
|
|
const postExecutionSnapshotMsRaw = performance.now() - postExecutionSnapshotStartedAt;
|
|
let autoExplainLogPath: string | null = null;
|
|
let autoExplainLogReadBytes = 0;
|
|
const snapshotErrors = [...new Set([logSnapshotBefore.error, logSnapshotAfter.error].filter((value): value is string => value != null))];
|
|
let autoExplainLogReadError = snapshotErrors.length > 0 ? snapshotErrors.join("; ") : null;
|
|
let autoExplainMarkerFound = false;
|
|
let autoExplainParseErrorCount = 0;
|
|
let autoExplainEntries: StatementExecutionMetrics["slowestStatements"] = [];
|
|
let autoExplainRawLogExcerpt: string | null = null;
|
|
const autoExplainReadParseStartedAt = performance.now();
|
|
const snapshotBefore = logSnapshotBefore.snapshot;
|
|
const snapshotAfterInitial = logSnapshotAfter.snapshot;
|
|
if (autoExplainSetupError == null && snapshotBefore != null && snapshotAfterInitial != null) {
|
|
const requestedReadWindowBytes = Math.max(
|
|
AUTO_EXPLAIN_LOG_SAMPLE_BYTES,
|
|
Math.min(AUTO_EXPLAIN_MAX_LOG_SAMPLE_BYTES, Math.floor(sqlScript.length * 4)),
|
|
);
|
|
const readAutoExplainCapture = async (snapshotAfterCurrent: PostgresLogSnapshot) => {
|
|
const logPathRotated = snapshotBefore.path !== snapshotAfterCurrent.path;
|
|
const logPath = logPathRotated
|
|
? `${snapshotBefore.path} -> ${snapshotAfterCurrent.path}`
|
|
: snapshotAfterCurrent.path;
|
|
|
|
let logReadBytes = 0;
|
|
let logContent = "";
|
|
const chunkReadErrors: string[] = [];
|
|
const pushChunk = (chunk: { content: string | null, error: string | null }, context: string) => {
|
|
if (chunk.error != null) {
|
|
chunkReadErrors.push(`${context}: ${chunk.error}`);
|
|
return;
|
|
}
|
|
if (chunk.content != null) {
|
|
logContent += chunk.content;
|
|
logReadBytes += chunk.content.length;
|
|
}
|
|
};
|
|
|
|
if (!logPathRotated) {
|
|
const readStartOffset = Math.max(
|
|
snapshotBefore.size,
|
|
snapshotAfterCurrent.size - requestedReadWindowBytes,
|
|
);
|
|
const readLength = Math.max(snapshotAfterCurrent.size - readStartOffset, 0);
|
|
const readLogChunkResult = await readPostgresLogChunk(snapshotAfterCurrent.path, readStartOffset, readLength);
|
|
pushChunk(readLogChunkResult, "active-log");
|
|
} else {
|
|
const readFromOldFile = await readPostgresLogChunk(
|
|
snapshotBefore.path,
|
|
snapshotBefore.size,
|
|
requestedReadWindowBytes,
|
|
);
|
|
pushChunk(readFromOldFile, "rotated-old-log");
|
|
if (logContent.length > 0) {
|
|
logContent += "\n";
|
|
}
|
|
const readFromNewFile = await readPostgresLogChunk(
|
|
snapshotAfterCurrent.path,
|
|
0,
|
|
Math.min(snapshotAfterCurrent.size, requestedReadWindowBytes),
|
|
);
|
|
pushChunk(readFromNewFile, "rotated-new-log");
|
|
}
|
|
|
|
const betweenMarkers = extractTextBetweenMarkers(
|
|
logContent,
|
|
autoExplainStartMarker,
|
|
autoExplainEndMarker,
|
|
);
|
|
const parsedAutoExplainEntries = parseAutoExplainEntries(logContent);
|
|
const preferredExcerptSource = betweenMarkers.text.includes("plan:")
|
|
? betweenMarkers.text
|
|
: logContent;
|
|
const logReadError = logContent.length === 0
|
|
? (
|
|
chunkReadErrors.length > 0
|
|
? chunkReadErrors.join("; ")
|
|
: "PostgreSQL log chunk was empty"
|
|
)
|
|
: null;
|
|
return {
|
|
logPath,
|
|
logReadBytes,
|
|
logReadError,
|
|
markerFound: betweenMarkers.markerFound,
|
|
parsedEntries: parsedAutoExplainEntries.parsedEntries,
|
|
parseErrorCount: parsedAutoExplainEntries.parseErrorCount,
|
|
rawLogExcerpt: preferredExcerptSource.length <= AUTO_EXPLAIN_LOG_EXCERPT_CHARS
|
|
? preferredExcerptSource
|
|
: preferredExcerptSource.slice(-AUTO_EXPLAIN_LOG_EXCERPT_CHARS),
|
|
partialErrors: chunkReadErrors,
|
|
};
|
|
};
|
|
|
|
let capture = await readAutoExplainCapture(snapshotAfterInitial);
|
|
for (let attempt = 1; attempt < AUTO_EXPLAIN_CAPTURE_RETRY_ATTEMPTS; attempt++) {
|
|
if (capture.parsedEntries.length >= statements.length) break;
|
|
await sleepMs(AUTO_EXPLAIN_CAPTURE_RETRY_DELAY_MS);
|
|
const retrySnapshotAfter = await getCurrentPostgresLogSnapshot();
|
|
if (retrySnapshotAfter.snapshot == null) continue;
|
|
const retryCapture = await readAutoExplainCapture(retrySnapshotAfter.snapshot);
|
|
if (retryCapture.parsedEntries.length > capture.parsedEntries.length) {
|
|
capture = retryCapture;
|
|
}
|
|
}
|
|
|
|
if (capture.partialErrors.length > 0 && capture.logReadError == null) {
|
|
console.warn(`[studio] partial auto_explain log read: ${capture.partialErrors.join("; ")}`);
|
|
}
|
|
autoExplainLogPath = capture.logPath;
|
|
autoExplainLogReadBytes = capture.logReadBytes;
|
|
autoExplainLogReadError = capture.logReadError;
|
|
autoExplainMarkerFound = capture.markerFound;
|
|
autoExplainEntries = capture.parsedEntries;
|
|
autoExplainParseErrorCount = capture.parseErrorCount;
|
|
autoExplainRawLogExcerpt = capture.rawLogExcerpt;
|
|
} else if (autoExplainSetupError == null && autoExplainLogReadError == null) {
|
|
autoExplainLogReadError = "PostgreSQL log snapshot unavailable (pg_current_logfile / pg_stat_file returned no path/size)";
|
|
}
|
|
const autoExplainReadParseMsRaw = performance.now() - autoExplainReadParseStartedAt;
|
|
|
|
const autoExplainCaptureAvailable = autoExplainSetupError == null
|
|
&& autoExplainLogReadError == null
|
|
&& autoExplainLogPath != null
|
|
&& autoExplainMarkerFound;
|
|
|
|
const metricsAssemblyStartedAt = performance.now();
|
|
const tableReferenceCounts = new Map<string, number>();
|
|
for (const statement of statements) {
|
|
const matches = statement.sql.match(/external:[A-Za-z0-9-]+/g) ?? [];
|
|
const uniqueTableIds = new Set(matches);
|
|
for (const tableId of uniqueTableIds) {
|
|
tableReferenceCounts.set(tableId, (tableReferenceCounts.get(tableId) ?? 0) + 1);
|
|
}
|
|
}
|
|
const topTableReferences = [...tableReferenceCounts.entries()]
|
|
.sort((a, b) => b[1] - a[1] || stringCompare(a[0], b[0]))
|
|
.slice(0, 8)
|
|
.map(([tableId, statementReferences]) => ({ tableId, statementReferences }));
|
|
const toStatementPreview = (statement: SqlStatement, index: number) => ({
|
|
index,
|
|
outputName: statement.outputName ?? null,
|
|
sqlPreview: statement.sql.length <= STATEMENT_SQL_PREVIEW_CHARS
|
|
? statement.sql
|
|
: `${statement.sql.slice(0, STATEMENT_SQL_PREVIEW_CHARS)}...`,
|
|
});
|
|
const lastPreviewStartIndex = Math.max(statements.length - 5, 0);
|
|
const slowestStatements = [...autoExplainEntries]
|
|
.sort((a, b) => b.wallMs - a.wallMs)
|
|
.slice(0, SLOW_STATEMENT_LIMIT);
|
|
const totalPlanningMs = autoExplainEntries.reduce((sum, entry) => sum + (entry.planningMs ?? 0), 0);
|
|
const totalExecutionMs = autoExplainEntries.reduce((sum, entry) => sum + (entry.executionMs ?? 0), 0);
|
|
const totalAutoExplainDurationMs = autoExplainEntries.reduce((sum, entry) => sum + entry.wallMs, 0);
|
|
const capturedNonPlannerExecutionMsRaw = Math.max(0, totalAutoExplainDurationMs - (totalPlanningMs + totalExecutionMs));
|
|
const uncapturedExecutionMsRaw = Math.max(0, statementWallMsTotalRaw - totalAutoExplainDurationMs);
|
|
const explainedStatementCount = autoExplainEntries.length;
|
|
const nestedAutoExplainEntryCount = Math.max(explainedStatementCount - statements.length, 0);
|
|
const capturedExecutableStatementCount = Math.min(explainedStatementCount, statements.length);
|
|
const notExplainedStatementCount = Math.max(statements.length - capturedExecutableStatementCount, 0);
|
|
const expectedRowChangeStatementsByTableId = new Map<string, number>();
|
|
for (const statement of statements) {
|
|
const rawTableId = parseRowChangeDiagnosticTableId(statement.sql);
|
|
if (rawTableId == null) continue;
|
|
const tableId = canonicalizeDiagnosticTableId(rawTableId);
|
|
expectedRowChangeStatementsByTableId.set(
|
|
tableId,
|
|
(expectedRowChangeStatementsByTableId.get(tableId) ?? 0) + 1,
|
|
);
|
|
}
|
|
const capturedRowChangeStatsByTableId = new Map<string, {
|
|
changedRows: number,
|
|
capturedStatementCount: number,
|
|
seenStatementKeys: Set<string>,
|
|
}>();
|
|
for (const entry of autoExplainEntries) {
|
|
if (entry.rowChangeDiagnosticTableId == null) continue;
|
|
const changedRows = Math.max(0, Math.floor(entry.rowChangeObservedRows ?? 0));
|
|
const existing = capturedRowChangeStatsByTableId.get(entry.rowChangeDiagnosticTableId) ?? {
|
|
changedRows: 0,
|
|
capturedStatementCount: 0,
|
|
seenStatementKeys: new Set<string>(),
|
|
};
|
|
const statementKey = entry.rowChangeDiagnosticStatementKey ?? `fallback:${entry.index}`;
|
|
if (existing.seenStatementKeys.has(statementKey)) {
|
|
continue;
|
|
}
|
|
existing.seenStatementKeys.add(statementKey);
|
|
capturedRowChangeStatsByTableId.set(entry.rowChangeDiagnosticTableId, {
|
|
changedRows: existing.changedRows + changedRows,
|
|
capturedStatementCount: existing.capturedStatementCount + 1,
|
|
seenStatementKeys: existing.seenStatementKeys,
|
|
});
|
|
}
|
|
const rowChangeDiagnosticTableIds = [...new Set([
|
|
...expectedRowChangeStatementsByTableId.keys(),
|
|
...capturedRowChangeStatsByTableId.keys(),
|
|
])];
|
|
const rowChangeDiagnostics = rowChangeDiagnosticTableIds
|
|
.map((tableId) => {
|
|
const expectedStatementCount = expectedRowChangeStatementsByTableId.get(tableId) ?? 0;
|
|
const capturedStats = capturedRowChangeStatsByTableId.get(tableId) ?? null;
|
|
return {
|
|
tableId,
|
|
changedRows: capturedStats == null ? null : capturedStats.changedRows,
|
|
capturedStatementCount: capturedStats?.capturedStatementCount ?? 0,
|
|
expectedStatementCount,
|
|
};
|
|
})
|
|
.sort((a, b) => {
|
|
const changedRowsA = a.changedRows ?? -1;
|
|
const changedRowsB = b.changedRows ?? -1;
|
|
return changedRowsB - changedRowsA || stringCompare(a.tableId, b.tableId);
|
|
});
|
|
const finishedAt = performance.now();
|
|
const metricsAssemblyMsRaw = finishedAt - metricsAssemblyStartedAt;
|
|
const durationMsRaw = finishedAt - startedAt;
|
|
const preparationMsRaw = executionStartedAt - startedAt;
|
|
const postProcessingMsRaw = finishedAt - postProcessingStartedAt;
|
|
const uncategorizedMsRaw = Math.max(
|
|
0,
|
|
durationMsRaw - preparationMsRaw - statementWallMsTotalRaw - postProcessingMsRaw,
|
|
);
|
|
const metrics: StatementExecutionMetrics = {
|
|
durationMs: Number(durationMsRaw.toFixed(1)),
|
|
statementCount: statements.length,
|
|
logicalStatementCount: statements.length,
|
|
executableStatementCount: statements.length,
|
|
sequentialStatementCount: statements.length,
|
|
uniqueTableReferenceCount: tableReferenceCounts.size,
|
|
sqlScriptLength: sqlScript.length,
|
|
sqlScript,
|
|
firstStatementPreviews: statements.slice(0, 5).map((statement, index) => toStatementPreview(statement, index)),
|
|
lastStatementPreviews: statements.slice(lastPreviewStartIndex).map((statement, index) => toStatementPreview(statement, lastPreviewStartIndex + index)),
|
|
topTableReferences,
|
|
timingBreakdown: {
|
|
buildSqlScriptMs: Number(buildSqlScriptMsRaw.toFixed(1)),
|
|
buildInstrumentationMs: Number(buildInstrumentationMsRaw.toFixed(1)),
|
|
preExecutionSnapshotMs: Number(preExecutionSnapshotMsRaw.toFixed(1)),
|
|
executePrimaryMs: Number(executePrimaryMsRaw.toFixed(1)),
|
|
executeFallbackMs: Number(executeFallbackMsRaw.toFixed(1)),
|
|
postExecutionSnapshotMs: Number(postExecutionSnapshotMsRaw.toFixed(1)),
|
|
autoExplainReadParseMs: Number(autoExplainReadParseMsRaw.toFixed(1)),
|
|
metricsAssemblyMs: Number(metricsAssemblyMsRaw.toFixed(1)),
|
|
preparationMs: Number(preparationMsRaw.toFixed(1)),
|
|
statementWallMsTotal,
|
|
postProcessingMs: Number(postProcessingMsRaw.toFixed(1)),
|
|
uncategorizedMs: Number(uncategorizedMsRaw.toFixed(1)),
|
|
totalPlanningMs: Number(totalPlanningMs.toFixed(1)),
|
|
totalExecutionMs: Number(totalExecutionMs.toFixed(1)),
|
|
totalAutoExplainDurationMs: Number(totalAutoExplainDurationMs.toFixed(1)),
|
|
capturedNonPlannerExecutionMs: Number(capturedNonPlannerExecutionMsRaw.toFixed(1)),
|
|
uncapturedExecutionMs: Number(uncapturedExecutionMsRaw.toFixed(1)),
|
|
explainedStatementCount,
|
|
nestedAutoExplainEntryCount,
|
|
capturedExecutableStatementCount,
|
|
notExplainedStatementCount,
|
|
},
|
|
slowestStatements,
|
|
rowChangeDiagnostics,
|
|
autoExplain: {
|
|
enabled: autoExplainCaptureAvailable,
|
|
setupError: autoExplainSetupError,
|
|
logReadError: autoExplainLogReadError,
|
|
logPath: autoExplainLogPath,
|
|
logReadBytes: autoExplainLogReadBytes,
|
|
markerFound: autoExplainMarkerFound,
|
|
parsedEntryCount: autoExplainEntries.length,
|
|
parseErrorCount: autoExplainParseErrorCount,
|
|
rawLogExcerpt: autoExplainRawLogExcerpt,
|
|
},
|
|
};
|
|
if (metrics.durationMs >= 1000) {
|
|
const topSummary = metrics.topTableReferences
|
|
.slice(0, 3)
|
|
.map((entry) => `${entry.tableId}(${entry.statementReferences})`)
|
|
.join(", ");
|
|
const timingSummary = `auto_explain_duration=${metrics.timingBreakdown.totalAutoExplainDurationMs}ms planning=${metrics.timingBreakdown.totalPlanningMs}ms execution=${metrics.timingBreakdown.totalExecutionMs}ms entries=${metrics.timingBreakdown.explainedStatementCount}`;
|
|
console.log(`[studio] slow mutation ${metrics.durationMs}ms (${metrics.statementCount} statements) ${timingSummary} topRefs=${topSummary}`);
|
|
}
|
|
return metrics;
|
|
}
|
|
|
|
async function queryRows(query: SqlQuery): Promise<unknown[]> {
|
|
const rows = await retryTransaction(globalPrismaClient, async (tx) => {
|
|
return await tx.$queryRawUnsafe<unknown[]>(toQueryableSqlQuery(query));
|
|
});
|
|
if (!Array.isArray(rows)) throw new StackAssertionError("Expected SQL query to return an array of rows.");
|
|
return rows;
|
|
}
|
|
|
|
async function readBoolean(expression: SqlExpression<boolean>): Promise<boolean> {
|
|
const rows = await retryTransaction(globalPrismaClient, async (tx) => {
|
|
return await tx.$queryRawUnsafe<Array<Record<string, unknown>>>(`SELECT (${expression.sql}) AS "value"`);
|
|
});
|
|
if (!Array.isArray(rows) || rows.length === 0 || !isRecord(rows[0])) {
|
|
throw new StackAssertionError("Expected boolean expression query to return one row.");
|
|
}
|
|
return Reflect.get(rows[0], "value") === true;
|
|
}
|
|
|
|
function valueFromRow(row: unknown, key: string): unknown {
|
|
if (!isRecord(row)) return null;
|
|
return Reflect.get(row, key);
|
|
}
|
|
|
|
async function getTableSnapshot(record: StudioTableRecord): Promise<{
|
|
id: string,
|
|
name: string,
|
|
tableId: string,
|
|
operator: string,
|
|
dependencies: string[],
|
|
debugArgs: Record<string, unknown>,
|
|
supportsSetRow: boolean,
|
|
supportsDeleteRow: boolean,
|
|
initialized: boolean,
|
|
}> {
|
|
const inputTables = record.table.inputTables ?? [];
|
|
const debugArgs = record.table.debugArgs ?? {};
|
|
const dependsOn = inputTables.map((inputTable) => {
|
|
return registry.idByTable.get(inputTable) ?? tableIdToString(inputTable.tableId);
|
|
});
|
|
const operatorValue = Reflect.get(debugArgs, "operator");
|
|
const operator = typeof operatorValue === "string" ? operatorValue : "unknown";
|
|
|
|
const executionContext = createBulldozerExecutionContext();
|
|
return {
|
|
id: record.id,
|
|
name: record.name,
|
|
tableId: tableIdToString(record.table.tableId),
|
|
operator,
|
|
dependencies: dependsOn,
|
|
debugArgs,
|
|
supportsSetRow: isStudioStoredTable(record.table),
|
|
supportsDeleteRow: isStudioStoredTable(record.table),
|
|
initialized: await readBoolean(record.table.isInitialized(executionContext)),
|
|
};
|
|
}
|
|
|
|
function topologicallySortTableIds(
|
|
tables: Array<Awaited<ReturnType<typeof getTableSnapshot>>>,
|
|
): string[] {
|
|
const ids = new Set(tables.map((table) => table.id));
|
|
const outgoing = new Map<string, string[]>();
|
|
const inDegree = new Map<string, number>();
|
|
|
|
for (const table of tables) {
|
|
outgoing.set(table.id, []);
|
|
inDegree.set(table.id, 0);
|
|
}
|
|
|
|
for (const table of tables) {
|
|
for (const dependencyId of table.dependencies) {
|
|
if (!ids.has(dependencyId)) continue;
|
|
const next = outgoing.get(dependencyId);
|
|
if (next == null) continue;
|
|
next.push(table.id);
|
|
const currentInDegree = inDegree.get(table.id);
|
|
if (currentInDegree == null) continue;
|
|
inDegree.set(table.id, currentInDegree + 1);
|
|
}
|
|
}
|
|
|
|
const queue = [...inDegree.entries()]
|
|
.filter((entry) => entry[1] === 0)
|
|
.map((entry) => entry[0])
|
|
.sort(stringCompare);
|
|
const ordered: string[] = [];
|
|
|
|
while (queue.length > 0) {
|
|
const id = queue.shift();
|
|
if (id == null) continue;
|
|
ordered.push(id);
|
|
const nextIds = outgoing.get(id) ?? [];
|
|
for (const nextId of nextIds) {
|
|
const currentInDegree = inDegree.get(nextId);
|
|
if (currentInDegree == null) continue;
|
|
const updatedInDegree = currentInDegree - 1;
|
|
inDegree.set(nextId, updatedInDegree);
|
|
if (updatedInDegree === 0) {
|
|
queue.push(nextId);
|
|
queue.sort(stringCompare);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (ordered.length === tables.length) return ordered;
|
|
|
|
const remaining = [...ids].filter((id) => !ordered.includes(id)).sort(stringCompare);
|
|
return [...ordered, ...remaining];
|
|
}
|
|
|
|
async function rebindInitializedDerivedTables(): Promise<void> {
|
|
const snapshots = await Promise.all(registry.tables.map((table) => getTableSnapshot(table)));
|
|
const initializedDerivedTableIds = new Set(
|
|
snapshots
|
|
.filter((table) => table.initialized && !table.supportsSetRow)
|
|
.map((table) => table.id),
|
|
);
|
|
if (initializedDerivedTableIds.size === 0) return;
|
|
|
|
const sortedIds = topologicallySortTableIds(snapshots);
|
|
const recordsToDelete = [...sortedIds]
|
|
.reverse()
|
|
.map((id) => registry.tableById.get(id))
|
|
.filter((record): record is StudioTableRecord => record != null && initializedDerivedTableIds.has(record.id));
|
|
const recordsToInit = sortedIds
|
|
.map((id) => registry.tableById.get(id))
|
|
.filter((record): record is StudioTableRecord => record != null && initializedDerivedTableIds.has(record.id));
|
|
|
|
for (const record of recordsToDelete) {
|
|
const executionContext = createBulldozerExecutionContext();
|
|
await executeStatements(record.table.delete(executionContext));
|
|
}
|
|
for (const record of recordsToInit) {
|
|
const executionContext = createBulldozerExecutionContext();
|
|
await executeStatements(record.table.init(executionContext));
|
|
}
|
|
|
|
console.log(`[studio] rebound ${recordsToInit.length} initialized derived tables`);
|
|
}
|
|
|
|
async function initAllTablesInTopologicalOrder(): Promise<string[]> {
|
|
const snapshots = await Promise.all(registry.tables.map((table) => getTableSnapshot(table)));
|
|
const snapshotById = new Map(snapshots.map((snapshot) => [snapshot.id, snapshot]));
|
|
const sortedIds = topologicallySortTableIds(snapshots);
|
|
const initializedIds: string[] = [];
|
|
|
|
for (const id of sortedIds) {
|
|
const snapshot = snapshotById.get(id);
|
|
if (snapshot == null || snapshot.initialized) continue;
|
|
const record = registry.tableById.get(id);
|
|
if (record == null) continue;
|
|
const executionContext = createBulldozerExecutionContext();
|
|
await executeStatements(record.table.init(executionContext));
|
|
initializedIds.push(id);
|
|
}
|
|
|
|
return initializedIds;
|
|
}
|
|
|
|
async function computeStudioLayout(tables: Array<Awaited<ReturnType<typeof getTableSnapshot>>>): Promise<null | {
|
|
positions: Record<string, { x: number, y: number }>,
|
|
sceneWidth: number,
|
|
sceneHeight: number,
|
|
}> {
|
|
try {
|
|
const layout = await elk.layout({
|
|
id: "bulldozer-studio",
|
|
layoutOptions: {
|
|
"elk.algorithm": "layered",
|
|
"elk.direction": "DOWN",
|
|
"elk.padding": `[top=${GRAPH_SCENE_MARGIN},left=${GRAPH_SCENE_MARGIN},bottom=${GRAPH_SCENE_MARGIN},right=${GRAPH_SCENE_MARGIN}]`,
|
|
"elk.spacing.nodeNode": String(Math.floor(GRAPH_COLUMN_GAP_X / 2)),
|
|
"elk.layered.spacing.nodeNodeBetweenLayers": String(Math.floor(GRAPH_LEVEL_GAP_Y / 2)),
|
|
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
|
|
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
|
|
"elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
|
|
"elk.layered.thoroughness": "40",
|
|
},
|
|
children: tables.map((table) => ({
|
|
id: table.id,
|
|
width: GRAPH_NODE_WIDTH,
|
|
height: GRAPH_NODE_HEIGHT,
|
|
})),
|
|
edges: tables.flatMap((table) => {
|
|
return table.dependencies.map((dependencyId, index) => ({
|
|
id: `${dependencyId}->${table.id}:${index}`,
|
|
sources: [dependencyId],
|
|
targets: [table.id],
|
|
}));
|
|
}),
|
|
});
|
|
|
|
const positions = new Map<string, { x: number, y: number }>();
|
|
for (const child of layout.children ?? []) {
|
|
if (typeof child.id !== "string") continue;
|
|
positions.set(child.id, {
|
|
x: Number(child.x ?? 0),
|
|
y: Number(child.y ?? 0),
|
|
});
|
|
}
|
|
|
|
return {
|
|
positions: Object.fromEntries(positions),
|
|
sceneWidth: Number(Reflect.get(layout, "width") ?? 600),
|
|
sceneHeight: Number(Reflect.get(layout, "height") ?? 600),
|
|
};
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function getTableDetails(record: StudioTableRecord): Promise<{
|
|
table: Awaited<ReturnType<typeof getTableSnapshot>>,
|
|
groups: Array<{ groupKey: unknown, rows: Array<{ rowIdentifier: unknown, rowSortKey: unknown, rowData: unknown }> }>,
|
|
totalRows: number,
|
|
}> {
|
|
const table = record.table;
|
|
const tableSnapshot = await getTableSnapshot(record);
|
|
const executionContext = createBulldozerExecutionContext();
|
|
const groupsRaw = await queryRows(table.listGroups(executionContext, {
|
|
start: "start",
|
|
end: "end",
|
|
startInclusive: true,
|
|
endInclusive: true,
|
|
}));
|
|
const allRowsRaw = await queryRows(table.listRowsInGroup(executionContext, {
|
|
start: "start",
|
|
end: "end",
|
|
startInclusive: true,
|
|
endInclusive: true,
|
|
}));
|
|
|
|
const rowsByGroup = new Map<string, { groupKey: unknown, rows: Array<{ rowIdentifier: unknown, rowSortKey: unknown, rowData: unknown }> }>();
|
|
|
|
for (const groupRow of groupsRaw) {
|
|
const groupKey = valueFromRow(groupRow, "groupkey");
|
|
const key = JSON.stringify(groupKey);
|
|
rowsByGroup.set(key, { groupKey, rows: [] });
|
|
}
|
|
|
|
for (const row of allRowsRaw) {
|
|
const hasGroupKey = isRecord(row) && Reflect.has(row, "groupkey");
|
|
const groupKey = hasGroupKey ? valueFromRow(row, "groupkey") : null;
|
|
const key = JSON.stringify(groupKey);
|
|
const existing = rowsByGroup.get(key) ?? { groupKey, rows: [] };
|
|
existing.rows.push({
|
|
rowIdentifier: valueFromRow(row, "rowidentifier"),
|
|
rowSortKey: valueFromRow(row, "rowsortkey"),
|
|
rowData: valueFromRow(row, "rowdata"),
|
|
});
|
|
rowsByGroup.set(key, existing);
|
|
}
|
|
|
|
const groups = [...rowsByGroup.values()].sort((a, b) => {
|
|
return stringCompare(JSON.stringify(a.groupKey), JSON.stringify(b.groupKey));
|
|
});
|
|
|
|
return {
|
|
table: tableSnapshot,
|
|
groups,
|
|
totalRows: allRowsRaw.length,
|
|
};
|
|
}
|
|
|
|
async function getTimefoldDebugSnapshot(): Promise<{
|
|
queueTableExists: boolean,
|
|
metadataTableExists: boolean,
|
|
pgCronInstalled: boolean,
|
|
lastProcessedAt: unknown,
|
|
queue: Array<Record<string, unknown>>,
|
|
}> {
|
|
return await retryTransaction(globalPrismaClient, async (tx) => {
|
|
const relationRows = await tx.$queryRawUnsafe<Array<Record<string, unknown>>>(`
|
|
SELECT
|
|
to_regclass('"BulldozerTimeFoldQueue"') IS NOT NULL AS "queueTableExists",
|
|
to_regclass('"BulldozerTimeFoldMetadata"') IS NOT NULL AS "metadataTableExists",
|
|
to_regclass('cron.job') IS NOT NULL AS "pgCronInstalled"
|
|
`);
|
|
const relationRow = requireRecord(relationRows[0], "timefold relation probe returned invalid row");
|
|
const queueTableExists = Reflect.get(relationRow, "queueTableExists") === true || Reflect.get(relationRow, "queuetableexists") === true;
|
|
const metadataTableExists = Reflect.get(relationRow, "metadataTableExists") === true || Reflect.get(relationRow, "metadatatableexists") === true;
|
|
const pgCronInstalled = Reflect.get(relationRow, "pgCronInstalled") === true || Reflect.get(relationRow, "pgcroninstalled") === true;
|
|
|
|
let lastProcessedAt: unknown = null;
|
|
if (metadataTableExists) {
|
|
const metadataRows = await tx.$queryRawUnsafe<Array<Record<string, unknown>>>(`
|
|
SELECT "lastProcessedAt"
|
|
FROM "BulldozerTimeFoldMetadata"
|
|
WHERE "key" = 'singleton'
|
|
LIMIT 1
|
|
`);
|
|
if (metadataRows.length > 0) {
|
|
const metadataRow = requireRecord(metadataRows[0], "timefold metadata query returned invalid row");
|
|
lastProcessedAt = Reflect.get(metadataRow, "lastProcessedAt") ?? Reflect.get(metadataRow, "lastprocessedat") ?? null;
|
|
}
|
|
}
|
|
|
|
let queue: Array<Record<string, unknown>> = [];
|
|
if (queueTableExists) {
|
|
queue = await tx.$queryRawUnsafe<Array<Record<string, unknown>>>(`
|
|
SELECT
|
|
"id",
|
|
"tableStoragePath",
|
|
"groupKey",
|
|
"rowIdentifier",
|
|
"scheduledAt",
|
|
"stateAfter",
|
|
"rowData",
|
|
"reducerSql",
|
|
"createdAt",
|
|
"updatedAt"
|
|
FROM "BulldozerTimeFoldQueue"
|
|
ORDER BY "scheduledAt" ASC, "id" ASC
|
|
LIMIT 500
|
|
`);
|
|
}
|
|
|
|
return {
|
|
queueTableExists,
|
|
metadataTableExists,
|
|
pgCronInstalled,
|
|
lastProcessedAt,
|
|
queue,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function getRawNode(pathSegments: string[]): Promise<{
|
|
path: string[],
|
|
value: unknown,
|
|
children: Array<{ segment: string, hasChildren: boolean }>,
|
|
}> {
|
|
const keyPathLiteral = keyPathSqlLiteral(pathSegments);
|
|
const { valueRows, childrenRows } = await retryTransaction(globalPrismaClient, async (tx) => {
|
|
const valueRows = await tx.$queryRawUnsafe<Array<Record<string, unknown>>>(`
|
|
SELECT "value"
|
|
FROM "BulldozerStorageEngine"
|
|
WHERE "keyPath" = ${keyPathLiteral}
|
|
`);
|
|
const childrenRows = await tx.$queryRawUnsafe<Array<Record<string, unknown>>>(`
|
|
SELECT
|
|
("child"."keyPath"[cardinality("child"."keyPath")] #>> '{}') AS "segment",
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM "BulldozerStorageEngine" AS "grandChild"
|
|
WHERE "grandChild"."keyPathParent" = "child"."keyPath"
|
|
) AS "hasChildren"
|
|
FROM "BulldozerStorageEngine" AS "child"
|
|
WHERE "child"."keyPathParent" = ${keyPathLiteral}
|
|
ORDER BY "segment"
|
|
`);
|
|
return { valueRows, childrenRows };
|
|
});
|
|
|
|
const children = childrenRows
|
|
.filter((row) => isRecord(row) && typeof Reflect.get(row, "segment") === "string")
|
|
.map((row) => ({
|
|
segment: requireString(Reflect.get(row, "segment"), "Expected segment to be a string."),
|
|
hasChildren: Reflect.get(row, "hasChildren") === true,
|
|
}));
|
|
|
|
return {
|
|
path: pathSegments,
|
|
value: Array.isArray(valueRows) && valueRows.length > 0 ? valueFromRow(valueRows[0], "value") : null,
|
|
children,
|
|
};
|
|
}
|
|
|
|
async function readRequestBody(request: http.IncomingMessage): Promise<string> {
|
|
const chunks: Buffer[] = [];
|
|
let totalBytes = 0;
|
|
for await (const chunk of request) {
|
|
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
totalBytes += chunkBuffer.byteLength;
|
|
if (totalBytes > MAX_REQUEST_BODY_BYTES) {
|
|
throw new StackAssertionError("Request body exceeds maximum size.", {
|
|
maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES,
|
|
receivedBytes: totalBytes,
|
|
});
|
|
}
|
|
if (Buffer.isBuffer(chunk)) {
|
|
chunks.push(chunkBuffer);
|
|
} else if (typeof chunk === "string") {
|
|
chunks.push(chunkBuffer);
|
|
}
|
|
}
|
|
return Buffer.concat(chunks).toString("utf8");
|
|
}
|
|
|
|
async function readJsonBody(request: http.IncomingMessage): Promise<unknown> {
|
|
const rawBody = await readRequestBody(request);
|
|
if (rawBody.trim() === "") return {};
|
|
return JSON.parse(rawBody);
|
|
}
|
|
|
|
function sendJson(response: http.ServerResponse, statusCode: number, payload: unknown): void {
|
|
response.statusCode = statusCode;
|
|
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
response.end(JSON.stringify(payload));
|
|
}
|
|
|
|
function sendHtml(response: http.ServerResponse, html: string): void {
|
|
response.statusCode = 200;
|
|
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
response.end(html);
|
|
}
|
|
|
|
function isLoopbackAddress(remoteAddress: string | undefined): boolean {
|
|
if (remoteAddress == null) return false;
|
|
return remoteAddress === "127.0.0.1"
|
|
|| remoteAddress === "::1"
|
|
|| remoteAddress === "::ffff:127.0.0.1";
|
|
}
|
|
|
|
function requireAuthorizedMutationRequest(request: http.IncomingMessage, requestUrl: URL): void {
|
|
const authHeader = request.headers[STUDIO_AUTH_HEADER];
|
|
const token = typeof authHeader === "string" ? authHeader : null;
|
|
if (token !== STUDIO_AUTH_TOKEN) {
|
|
throw new StackAssertionError("Invalid or missing studio mutation token.");
|
|
}
|
|
|
|
const originHeader = request.headers.origin;
|
|
if (typeof originHeader === "string") {
|
|
let originUrl: URL;
|
|
try {
|
|
originUrl = new URL(originHeader);
|
|
} catch {
|
|
throw new StackAssertionError("Mutation origin is not allowed.", {
|
|
origin: originHeader,
|
|
path: requestUrl.pathname,
|
|
});
|
|
}
|
|
|
|
const portMatches = originUrl.port === String(STUDIO_PORT);
|
|
const hostname = originUrl.hostname.toLowerCase();
|
|
const hostnameAllowed = hostname === "localhost"
|
|
|| hostname === "127.0.0.1"
|
|
|| hostname === "::1"
|
|
|| hostname.endsWith(".localhost");
|
|
if (!portMatches || !hostnameAllowed) {
|
|
throw new StackAssertionError("Mutation origin is not allowed.", {
|
|
origin: originHeader,
|
|
path: requestUrl.pathname,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function getStudioPageHtml(): string {
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Bulldozer Studio</title>
|
|
<style>
|
|
:root {
|
|
--bg: #111111;
|
|
--bg-alt: #171717;
|
|
--panel: #1f1f1f;
|
|
--line: #343434;
|
|
--grid: rgba(220, 220, 220, 0.08);
|
|
--text: #f2f2f2;
|
|
--muted: #b0b0b0;
|
|
--accent: #66a3ff;
|
|
--filter: #f7b955;
|
|
--danger: #ff5f56;
|
|
--ok: #35c769;
|
|
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
}
|
|
body[data-theme="light"] {
|
|
--bg: #f5f5f5;
|
|
--bg-alt: #ececec;
|
|
--panel: #ffffff;
|
|
--line: #cfcfcf;
|
|
--grid: rgba(0, 0, 0, 0.08);
|
|
--text: #111111;
|
|
--muted: #555555;
|
|
--accent: #245ee9;
|
|
--filter: #b06b00;
|
|
--danger: #d72638;
|
|
--ok: #118a3e;
|
|
}
|
|
* {
|
|
box-sizing: border-box;
|
|
border-radius: 0 !important;
|
|
}
|
|
html, body {
|
|
height: 100%;
|
|
}
|
|
body {
|
|
margin: 0;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: "Segoe UI", Inter, sans-serif;
|
|
overflow: hidden;
|
|
}
|
|
.app {
|
|
display: grid;
|
|
grid-template-rows: 52px 1fr;
|
|
height: 100vh;
|
|
}
|
|
.toolbar {
|
|
border-bottom: 1px solid var(--line);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 10px;
|
|
background: var(--bg-alt);
|
|
gap: 10px;
|
|
}
|
|
.toolbar-left, .toolbar-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
}
|
|
.title {
|
|
font-weight: 700;
|
|
letter-spacing: 0.02em;
|
|
margin-right: 10px;
|
|
white-space: nowrap;
|
|
}
|
|
.layout {
|
|
display: grid;
|
|
grid-template-columns: minmax(580px, 58%) 1fr;
|
|
min-height: 0;
|
|
}
|
|
.graph-pane {
|
|
border-right: 1px solid var(--line);
|
|
display: grid;
|
|
grid-template-rows: 1fr;
|
|
min-height: 0;
|
|
}
|
|
.details-pane {
|
|
overflow: auto;
|
|
padding: 10px;
|
|
min-height: 0;
|
|
}
|
|
.graph-shell {
|
|
position: relative;
|
|
overflow: hidden;
|
|
min-height: 0;
|
|
cursor: grab;
|
|
background-image:
|
|
linear-gradient(to right, var(--grid) 1px, transparent 1px),
|
|
linear-gradient(to bottom, var(--grid) 1px, transparent 1px);
|
|
background-size: 24px 24px;
|
|
background-position: 0 0;
|
|
border-top: 1px solid var(--line);
|
|
}
|
|
.graph-shell.dragging {
|
|
cursor: grabbing;
|
|
}
|
|
.graph-scene {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
transform-origin: 0 0;
|
|
will-change: transform;
|
|
}
|
|
.graph-edges {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
pointer-events: none;
|
|
overflow: visible;
|
|
}
|
|
.graph-nodes {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
}
|
|
.node {
|
|
position: absolute;
|
|
width: 260px;
|
|
min-height: 126px;
|
|
border: 1px solid var(--line);
|
|
background: var(--panel);
|
|
padding: 8px;
|
|
display: grid;
|
|
grid-template-rows: auto auto 1fr auto;
|
|
gap: 6px;
|
|
transition: border-color 0.15s ease;
|
|
cursor: grab;
|
|
user-select: none;
|
|
}
|
|
.node-debug-chip-wrap {
|
|
position: absolute;
|
|
top: 6px;
|
|
right: 6px;
|
|
display: flex;
|
|
gap: 4px;
|
|
z-index: 2;
|
|
pointer-events: none;
|
|
}
|
|
.node-debug-chip {
|
|
border: 1px solid var(--line);
|
|
background: var(--bg-alt);
|
|
color: var(--text);
|
|
font-size: 10px;
|
|
line-height: 1;
|
|
padding: 3px 6px;
|
|
border-radius: 999px;
|
|
font-family: var(--mono);
|
|
letter-spacing: 0.01em;
|
|
opacity: 0.95;
|
|
pointer-events: auto;
|
|
cursor: help;
|
|
}
|
|
.node-debug-chip.warn {
|
|
border-color: var(--filter);
|
|
color: var(--filter);
|
|
}
|
|
.node-debug-chip.nonzero {
|
|
border-color: #e14a4a;
|
|
background: #e14a4a;
|
|
color: #ffffff;
|
|
}
|
|
.node:hover {
|
|
border-color: var(--accent);
|
|
}
|
|
.node.active {
|
|
border-color: var(--accent);
|
|
box-shadow: inset 0 0 0 1px var(--accent);
|
|
}
|
|
.node.dragging {
|
|
cursor: grabbing;
|
|
z-index: 4;
|
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.22), inset 0 0 0 1px var(--accent);
|
|
}
|
|
.node-type {
|
|
font-size: 28px;
|
|
font-weight: 800;
|
|
line-height: 1;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
font-family: var(--mono);
|
|
}
|
|
.node-type.stored {
|
|
color: var(--ok);
|
|
}
|
|
.node-type.derived {
|
|
color: var(--accent);
|
|
}
|
|
.node-type.filter {
|
|
color: var(--filter);
|
|
}
|
|
.node-type.map {
|
|
color: var(--accent);
|
|
}
|
|
.node-type.flatmap {
|
|
color: color-mix(in srgb, var(--accent) 70%, var(--ok));
|
|
}
|
|
.node-type.groupby {
|
|
color: color-mix(in srgb, var(--accent) 80%, white);
|
|
}
|
|
.node-type.limit {
|
|
color: color-mix(in srgb, var(--filter) 75%, var(--text));
|
|
}
|
|
.node-type.concat {
|
|
color: color-mix(in srgb, var(--accent) 60%, var(--filter));
|
|
}
|
|
.node-type.sort {
|
|
color: color-mix(in srgb, var(--accent) 75%, var(--ok));
|
|
}
|
|
.node-type.lfold {
|
|
color: color-mix(in srgb, var(--accent) 60%, var(--danger));
|
|
}
|
|
.node-type.leftjoin {
|
|
color: color-mix(in srgb, var(--accent) 55%, var(--ok));
|
|
}
|
|
.node-type.timefold {
|
|
color: color-mix(in srgb, var(--filter) 60%, var(--danger));
|
|
}
|
|
.node-name {
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.node-name.stored {
|
|
color: var(--ok);
|
|
}
|
|
.node-name.derived {
|
|
color: var(--text);
|
|
}
|
|
.node-name.filter {
|
|
color: var(--filter);
|
|
}
|
|
.node-name.limit {
|
|
color: color-mix(in srgb, var(--filter) 85%, var(--text));
|
|
}
|
|
.node-name.concat {
|
|
color: color-mix(in srgb, var(--accent) 55%, var(--filter));
|
|
}
|
|
.node-name.sort {
|
|
color: color-mix(in srgb, var(--accent) 80%, var(--ok));
|
|
}
|
|
.node-name.lfold {
|
|
color: color-mix(in srgb, var(--accent) 70%, var(--danger));
|
|
}
|
|
.node-name.leftjoin {
|
|
color: color-mix(in srgb, var(--accent) 60%, var(--ok));
|
|
}
|
|
.node-name.timefold {
|
|
color: color-mix(in srgb, var(--filter) 65%, var(--danger));
|
|
}
|
|
.node-meta {
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
font-family: var(--mono);
|
|
}
|
|
.node-actions {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.mono {
|
|
font-family: var(--mono);
|
|
}
|
|
.btn {
|
|
border: 1px solid var(--line);
|
|
background: var(--panel);
|
|
color: var(--text);
|
|
padding: 6px 10px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
line-height: 1.1;
|
|
transition: border-color 0.15s ease;
|
|
}
|
|
.btn:hover {
|
|
border-color: var(--accent);
|
|
}
|
|
.btn:disabled {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
.btn.icon {
|
|
width: 30px;
|
|
min-width: 30px;
|
|
padding: 5px;
|
|
text-align: center;
|
|
font-size: 14px;
|
|
}
|
|
.btn.good {
|
|
border-color: color-mix(in srgb, var(--ok) 40%, var(--line));
|
|
}
|
|
.btn.bad {
|
|
border-color: color-mix(in srgb, var(--danger) 40%, var(--line));
|
|
}
|
|
.btn.active {
|
|
border-color: var(--accent);
|
|
box-shadow: inset 0 0 0 1px var(--accent);
|
|
}
|
|
.status-pill {
|
|
font-size: 11px;
|
|
border: 1px solid var(--line);
|
|
padding: 3px 6px;
|
|
white-space: nowrap;
|
|
color: var(--muted);
|
|
max-width: 320px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.detail-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
}
|
|
.detail-title {
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
}
|
|
.detail-section {
|
|
border: 1px solid var(--line);
|
|
margin-bottom: 10px;
|
|
padding: 8px;
|
|
background: var(--panel);
|
|
}
|
|
.kv {
|
|
display: grid;
|
|
grid-template-columns: 150px minmax(0, 1fr);
|
|
gap: 6px 8px;
|
|
font-size: 12px;
|
|
align-items: start;
|
|
}
|
|
.kv-key {
|
|
color: var(--muted);
|
|
}
|
|
pre {
|
|
margin: 0;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
border: 1px solid var(--line);
|
|
background: var(--bg-alt);
|
|
padding: 8px;
|
|
font-size: 12px;
|
|
font-family: var(--mono);
|
|
}
|
|
input, textarea {
|
|
width: 100%;
|
|
border: 1px solid var(--line);
|
|
background: var(--bg-alt);
|
|
color: var(--text);
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
padding: 6px 7px;
|
|
}
|
|
textarea {
|
|
min-height: 120px;
|
|
resize: vertical;
|
|
}
|
|
.row {
|
|
display: flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 12px;
|
|
}
|
|
th, td {
|
|
border: 1px solid var(--line);
|
|
padding: 5px 6px;
|
|
text-align: left;
|
|
vertical-align: top;
|
|
}
|
|
th {
|
|
background: var(--bg-alt);
|
|
font-weight: 600;
|
|
}
|
|
details {
|
|
border: 1px solid var(--line);
|
|
margin-bottom: 8px;
|
|
background: var(--panel);
|
|
}
|
|
summary {
|
|
cursor: pointer;
|
|
padding: 6px 7px;
|
|
border-bottom: 1px solid var(--line);
|
|
font-size: 12px;
|
|
font-family: var(--mono);
|
|
background: var(--bg-alt);
|
|
}
|
|
.raw-children {
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
}
|
|
dialog {
|
|
border: 1px solid var(--line);
|
|
background: var(--panel);
|
|
color: var(--text);
|
|
width: min(760px, 90vw);
|
|
max-height: 75vh;
|
|
padding: 0;
|
|
}
|
|
dialog::backdrop {
|
|
background: rgba(0, 0, 0, 0.45);
|
|
}
|
|
.dialog-content {
|
|
padding: 10px;
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
.dialog-title {
|
|
font-weight: 700;
|
|
color: var(--danger);
|
|
}
|
|
.muted {
|
|
color: var(--muted);
|
|
}
|
|
.metrics-visual {
|
|
display: grid;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
}
|
|
.metrics-grid {
|
|
display: grid;
|
|
gap: 8px;
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
}
|
|
.metrics-card {
|
|
border: 1px solid var(--line);
|
|
background: var(--bg-alt);
|
|
padding: 8px;
|
|
display: grid;
|
|
gap: 6px;
|
|
}
|
|
.metrics-card-title {
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--muted);
|
|
}
|
|
.metrics-big-value {
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
}
|
|
.metrics-kv {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
.metrics-bar {
|
|
height: 8px;
|
|
border: 1px solid var(--line);
|
|
background: var(--panel);
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.metrics-bar-fill {
|
|
position: absolute;
|
|
inset: 0 auto 0 0;
|
|
width: 0%;
|
|
background: var(--accent);
|
|
}
|
|
.metrics-bar-fill.good {
|
|
background: var(--ok);
|
|
}
|
|
.metrics-bar-fill.warn {
|
|
background: var(--filter);
|
|
}
|
|
.metrics-bar-fill.danger {
|
|
background: var(--danger);
|
|
}
|
|
.metrics-list {
|
|
margin: 0;
|
|
padding-left: 18px;
|
|
display: grid;
|
|
gap: 4px;
|
|
}
|
|
.metrics-list li {
|
|
color: var(--muted);
|
|
line-height: 1.35;
|
|
}
|
|
.metrics-empty {
|
|
color: var(--muted);
|
|
border: 1px dashed var(--line);
|
|
padding: 8px;
|
|
background: var(--bg-alt);
|
|
}
|
|
.metrics-timeline-shell {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
.metrics-timeline-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
}
|
|
.metrics-timeline-controls input[type="range"] {
|
|
width: 140px;
|
|
padding: 0;
|
|
margin: 0;
|
|
background: transparent;
|
|
border: none;
|
|
}
|
|
.metrics-timeline-scroll {
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
border: 1px solid var(--line);
|
|
background: var(--panel);
|
|
padding: 8px;
|
|
}
|
|
.metrics-timeline-track {
|
|
position: relative;
|
|
min-height: 62px;
|
|
}
|
|
.metrics-timeline-ruler {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
top: 0;
|
|
height: 16px;
|
|
}
|
|
.metrics-timeline-tick {
|
|
position: absolute;
|
|
top: 0;
|
|
width: 1px;
|
|
height: 16px;
|
|
background: var(--line);
|
|
}
|
|
.metrics-timeline-tick-label {
|
|
position: absolute;
|
|
top: 0;
|
|
transform: translateX(-50%);
|
|
font-size: 10px;
|
|
color: var(--muted);
|
|
white-space: nowrap;
|
|
}
|
|
.metrics-timeline-lane {
|
|
position: relative;
|
|
margin-top: 18px;
|
|
height: 18px;
|
|
border: 1px solid var(--line);
|
|
background: var(--bg-alt);
|
|
}
|
|
.metrics-timeline-lane-label {
|
|
margin-top: 4px;
|
|
font-size: 10px;
|
|
color: var(--muted);
|
|
font-family: var(--mono);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
.metrics-timeline-segment {
|
|
position: absolute;
|
|
top: 1px;
|
|
height: 14px;
|
|
border: 1px solid color-mix(in srgb, var(--line) 50%, transparent);
|
|
background: color-mix(in srgb, var(--accent) 45%, transparent);
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
font-size: 10px;
|
|
line-height: 12px;
|
|
padding: 0 3px;
|
|
color: var(--text);
|
|
}
|
|
.metrics-timeline-segment.exec {
|
|
background: color-mix(in srgb, var(--ok) 40%, transparent);
|
|
}
|
|
.metrics-timeline-segment.post {
|
|
background: color-mix(in srgb, var(--filter) 42%, transparent);
|
|
}
|
|
.metrics-timeline-segment.planexec {
|
|
background: color-mix(in srgb, var(--danger) 45%, transparent);
|
|
}
|
|
.metrics-timeline-segment.warn {
|
|
background: color-mix(in srgb, var(--filter) 45%, transparent);
|
|
}
|
|
.metrics-timeline-segment.untracked {
|
|
background: color-mix(in srgb, var(--muted) 35%, transparent);
|
|
}
|
|
.metrics-timeline-note {
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
line-height: 1.35;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body data-theme="dark">
|
|
<div class="app">
|
|
<header class="toolbar">
|
|
<div class="toolbar-left">
|
|
<div class="title">Bulldozer Studio</div>
|
|
<button id="modeTablesBtn" class="btn active">🧩 Tables</button>
|
|
<button id="modeRawBtn" class="btn">🗂️ Raw</button>
|
|
<select id="schemaSelect" class="btn" title="Switch schema" style="appearance:auto;padding:2px 6px;font-size:12px;"></select>
|
|
<button id="toggleIntermediatesBtn" class="btn" title="Show/hide map, filter, flatmap tables" style="font-size:11px;">👁 Intermediates</button>
|
|
<button id="modeTimefoldBtn" class="btn">⏱️ Timefold</button>
|
|
<button id="initAllBtn" class="btn good">🚀 init all</button>
|
|
<button id="refreshBtn" class="btn icon" title="Refresh">🔄</button>
|
|
<button id="fitBtn" class="btn icon" title="Fit graph">🧭</button>
|
|
<button id="themeBtn" class="btn icon" title="Toggle theme">🌙</button>
|
|
</div>
|
|
<div class="toolbar-right">
|
|
<div class="status-pill mono" id="statusText">ready</div>
|
|
</div>
|
|
</header>
|
|
<main class="layout">
|
|
<section class="graph-pane">
|
|
<div id="graphShell" class="graph-shell">
|
|
<div id="graphScene" class="graph-scene">
|
|
<svg id="graphEdges" class="graph-edges"></svg>
|
|
<div id="graphNodes" class="graph-nodes"></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section class="details-pane" id="detailsPane"></section>
|
|
</main>
|
|
</div>
|
|
|
|
<dialog id="errorDialog">
|
|
<div class="dialog-content">
|
|
<div class="dialog-title">Action failed</div>
|
|
<pre id="errorText"></pre>
|
|
<div class="row">
|
|
<button id="errorCloseBtn" class="btn">Close</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
|
|
<dialog id="metricsDialog">
|
|
<div class="dialog-content">
|
|
<div class="dialog-title" style="color:var(--text);" id="metricsDialogTitle">Execution details</div>
|
|
<div id="metricsDialogMeta" class="mono muted"></div>
|
|
<div id="metricsDialogVisual" class="metrics-visual"></div>
|
|
<pre id="metricsDialogText"></pre>
|
|
<div class="row">
|
|
<button id="metricsDialogCloseBtn" class="btn">Close</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
|
|
<script>
|
|
const NODE_WIDTH = ${GRAPH_NODE_WIDTH};
|
|
const NODE_HEIGHT = ${GRAPH_NODE_HEIGHT};
|
|
const LEVEL_GAP_Y = ${GRAPH_LEVEL_GAP_Y};
|
|
const COLUMN_GAP_X = ${GRAPH_COLUMN_GAP_X};
|
|
const SCENE_MARGIN = ${GRAPH_SCENE_MARGIN};
|
|
const STUDIO_AUTH_TOKEN = ${JSON.stringify(STUDIO_AUTH_TOKEN)};
|
|
const THEME_STORAGE_KEY = "bulldozer-studio-theme";
|
|
const NODE_POSITIONS_STORAGE_KEY = "bulldozer-studio-node-positions-v1";
|
|
const VERSION_POLL_INTERVAL_MS = 1200;
|
|
|
|
function loadStoredNodePositions() {
|
|
try {
|
|
const raw = window.localStorage.getItem(NODE_POSITIONS_STORAGE_KEY);
|
|
if (!raw) return {};
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
const result = {};
|
|
for (const [key, value] of Object.entries(parsed)) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
const x = Number(value.x);
|
|
const y = Number(value.y);
|
|
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
|
result[key] = { x, y };
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
return {};
|
|
}
|
|
}
|
|
function persistNodePositions() {
|
|
window.localStorage.setItem(NODE_POSITIONS_STORAGE_KEY, JSON.stringify(state.manualNodePositions));
|
|
}
|
|
|
|
const INTERMEDIATE_OPERATORS = new Set(["map", "filter", "flatmap"]);
|
|
|
|
const state = {
|
|
mode: "table",
|
|
schema: null,
|
|
selectedTableId: null,
|
|
selectedTableDetails: null,
|
|
lastMutationMetrics: {},
|
|
lastRowChangeDiagnostics: new Map(),
|
|
metricsTimelineZoom: 1.5,
|
|
rawNode: null,
|
|
rawPath: [],
|
|
timefoldDebug: null,
|
|
status: "ready",
|
|
theme: "dark",
|
|
serverVersion: null,
|
|
graphLayout: null,
|
|
showIntermediates: true,
|
|
viewport: {
|
|
x: 24,
|
|
y: 24,
|
|
scale: 1,
|
|
},
|
|
manualNodePositions: loadStoredNodePositions(),
|
|
dragging: {
|
|
active: false,
|
|
kind: null,
|
|
startX: 0,
|
|
startY: 0,
|
|
startOffsetX: 0,
|
|
startOffsetY: 0,
|
|
nodeId: null,
|
|
nodeStartX: 0,
|
|
nodeStartY: 0,
|
|
moved: false,
|
|
suppressClickTableId: null,
|
|
},
|
|
};
|
|
|
|
const graphShell = document.getElementById("graphShell");
|
|
const graphScene = document.getElementById("graphScene");
|
|
const graphEdges = document.getElementById("graphEdges");
|
|
const graphNodes = document.getElementById("graphNodes");
|
|
const detailsPane = document.getElementById("detailsPane");
|
|
const statusText = document.getElementById("statusText");
|
|
const errorDialog = document.getElementById("errorDialog");
|
|
const errorText = document.getElementById("errorText");
|
|
const errorCloseBtn = document.getElementById("errorCloseBtn");
|
|
const metricsDialog = document.getElementById("metricsDialog");
|
|
const metricsDialogTitle = document.getElementById("metricsDialogTitle");
|
|
const metricsDialogMeta = document.getElementById("metricsDialogMeta");
|
|
const metricsDialogVisual = document.getElementById("metricsDialogVisual");
|
|
const metricsDialogText = document.getElementById("metricsDialogText");
|
|
const metricsDialogCloseBtn = document.getElementById("metricsDialogCloseBtn");
|
|
const modeTablesBtn = document.getElementById("modeTablesBtn");
|
|
const modeRawBtn = document.getElementById("modeRawBtn");
|
|
const schemaSelect = document.getElementById("schemaSelect");
|
|
const toggleIntermediatesBtn = document.getElementById("toggleIntermediatesBtn");
|
|
const modeTimefoldBtn = document.getElementById("modeTimefoldBtn");
|
|
const initAllBtn = document.getElementById("initAllBtn");
|
|
const refreshBtn = document.getElementById("refreshBtn");
|
|
const fitBtn = document.getElementById("fitBtn");
|
|
const themeBtn = document.getElementById("themeBtn");
|
|
|
|
async function loadSchemaList() {
|
|
const data = await fetchJson("/api/schemas");
|
|
schemaSelect.innerHTML = "";
|
|
for (const name of data.available) {
|
|
const opt = document.createElement("option");
|
|
opt.value = name;
|
|
opt.textContent = name;
|
|
if (name === data.current) opt.selected = true;
|
|
schemaSelect.appendChild(opt);
|
|
}
|
|
}
|
|
|
|
function setStatus(text) {
|
|
state.status = text;
|
|
statusText.textContent = text;
|
|
}
|
|
|
|
function resolveSystemTheme() {
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
}
|
|
|
|
function showErrorDialog(message) {
|
|
errorText.textContent = message;
|
|
if (errorDialog.open) {
|
|
errorDialog.close();
|
|
}
|
|
errorDialog.showModal();
|
|
}
|
|
|
|
function readFiniteNumber(value) {
|
|
const numberValue = Number(value);
|
|
return Number.isFinite(numberValue) ? numberValue : null;
|
|
}
|
|
|
|
function readMutationDurationMs(metrics) {
|
|
if (!metrics || typeof metrics !== "object") return null;
|
|
return readFiniteNumber(metrics.durationMs);
|
|
}
|
|
|
|
function statementCountFromMetrics(metrics) {
|
|
if (!metrics || typeof metrics !== "object") return null;
|
|
return readFiniteNumber(metrics.logicalStatementCount ?? metrics.statementCount);
|
|
}
|
|
|
|
function executableStatementCountFromMetrics(metrics) {
|
|
if (!metrics || typeof metrics !== "object") return null;
|
|
return readFiniteNumber(metrics.executableStatementCount);
|
|
}
|
|
|
|
function canonicalizeDiagnosticTableId(tableId) {
|
|
if (typeof tableId !== "string") return tableId;
|
|
if (!tableId.startsWith("{")) return tableId;
|
|
try {
|
|
const parsed = JSON.parse(tableId);
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return tableId;
|
|
const parent = parsed.parent;
|
|
if (typeof parent === "string") return parent;
|
|
if (parent && typeof parent === "object" && !Array.isArray(parent)) {
|
|
return canonicalizeDiagnosticTableId(JSON.stringify(parent));
|
|
}
|
|
return tableId;
|
|
} catch {
|
|
return tableId;
|
|
}
|
|
}
|
|
|
|
function updateRowChangeDiagnosticsFromMetrics(metrics) {
|
|
state.lastRowChangeDiagnostics.clear();
|
|
if (!metrics || typeof metrics !== "object") return;
|
|
const diagnostics = Array.isArray(metrics.rowChangeDiagnostics) ? metrics.rowChangeDiagnostics : [];
|
|
for (const entry of diagnostics) {
|
|
const tableId = typeof entry?.tableId === "string" ? entry.tableId : null;
|
|
if (tableId == null || tableId.trim() === "") continue;
|
|
const changedRowsRaw = readFiniteNumber(entry?.changedRows);
|
|
const capturedStatementCount = readFiniteNumber(entry?.capturedStatementCount);
|
|
const expectedStatementCount = readFiniteNumber(entry?.expectedStatementCount);
|
|
state.lastRowChangeDiagnostics.set(tableId, {
|
|
changedRows: changedRowsRaw == null ? null : Math.max(0, Math.floor(changedRowsRaw)),
|
|
capturedStatementCount: capturedStatementCount == null ? null : Math.max(0, Math.floor(capturedStatementCount)),
|
|
expectedStatementCount: expectedStatementCount == null ? null : Math.max(0, Math.floor(expectedStatementCount)),
|
|
});
|
|
}
|
|
}
|
|
|
|
function changedRowsForTable(table) {
|
|
if (!table || typeof table !== "object") return null;
|
|
const tableId = typeof table.tableId === "string" ? table.tableId : null;
|
|
if (tableId != null) {
|
|
const directMatch = state.lastRowChangeDiagnostics.get(tableId);
|
|
if (directMatch != null) return directMatch;
|
|
const canonicalTableId = canonicalizeDiagnosticTableId(tableId);
|
|
if (canonicalTableId !== tableId) {
|
|
const canonicalMatch = state.lastRowChangeDiagnostics.get(canonicalTableId);
|
|
if (canonicalMatch != null) return canonicalMatch;
|
|
}
|
|
const withExternalPrefix = state.lastRowChangeDiagnostics.get("external:" + tableId);
|
|
if (withExternalPrefix != null) return withExternalPrefix;
|
|
if (tableId.startsWith("external:")) {
|
|
const withoutPrefix = state.lastRowChangeDiagnostics.get(tableId.slice("external:".length));
|
|
if (withoutPrefix != null) return withoutPrefix;
|
|
}
|
|
}
|
|
const registryId = typeof table.id === "string" ? table.id : null;
|
|
if (registryId != null) {
|
|
const registryMatch = state.lastRowChangeDiagnostics.get(registryId);
|
|
if (registryMatch != null) return registryMatch;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function formatNumericValue(value, fractionDigits = 1) {
|
|
if (!Number.isFinite(value)) return "n/a";
|
|
return Number(value).toFixed(fractionDigits);
|
|
}
|
|
|
|
function percentage(value, total) {
|
|
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return 0;
|
|
return Math.max(0, Math.min(100, (value / total) * 100));
|
|
}
|
|
|
|
function renderMetricsBarRow(label, valueText, widthPercent, tone = "good") {
|
|
const safeWidth = Number.isFinite(widthPercent) ? Math.max(0, Math.min(100, widthPercent)) : 0;
|
|
return ""
|
|
+ "<div class='metrics-kv'><span>" + escapeHtml(label) + "</span><span>" + escapeHtml(valueText) + "</span></div>"
|
|
+ "<div class='metrics-bar'><div class='metrics-bar-fill " + escapeHtml(tone) + "' style='width:" + safeWidth.toFixed(1) + "%'></div></div>";
|
|
}
|
|
|
|
function clampNumber(value, minValue, maxValue) {
|
|
if (!Number.isFinite(value)) return minValue;
|
|
return Math.max(minValue, Math.min(maxValue, value));
|
|
}
|
|
|
|
function layoutMetricsTimeline(container, zoom) {
|
|
const safeZoom = clampNumber(zoom, 0.5, 12);
|
|
const zoomLabel = container.querySelector("[data-metrics-timeline-zoom-value]");
|
|
if (zoomLabel) {
|
|
zoomLabel.textContent = safeZoom.toFixed(1) + "x";
|
|
}
|
|
const tracks = container.querySelectorAll("[data-metrics-timeline-track]");
|
|
for (const track of tracks) {
|
|
const totalMs = Number(track.getAttribute("data-total-ms"));
|
|
if (!Number.isFinite(totalMs) || totalMs <= 0) continue;
|
|
const widthPx = Math.max(720, Math.round(totalMs * 1.4 * safeZoom));
|
|
track.style.width = widthPx + "px";
|
|
const ticks = track.querySelectorAll("[data-metrics-timeline-tick-ratio]");
|
|
for (const tick of ticks) {
|
|
const ratio = Number(tick.getAttribute("data-metrics-timeline-tick-ratio"));
|
|
tick.style.left = (ratio * widthPx).toFixed(1) + "px";
|
|
}
|
|
const segments = track.querySelectorAll("[data-metrics-timeline-segment]");
|
|
for (const segment of segments) {
|
|
const startMs = Number(segment.getAttribute("data-start-ms"));
|
|
const durationMs = Number(segment.getAttribute("data-duration-ms"));
|
|
const leftPx = (clampNumber(startMs, 0, totalMs) / totalMs) * widthPx;
|
|
const segmentWidthPx = Math.max(2, (clampNumber(durationMs, 0, totalMs) / totalMs) * widthPx);
|
|
segment.style.left = leftPx.toFixed(1) + "px";
|
|
segment.style.width = segmentWidthPx.toFixed(1) + "px";
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderMetricsVisual(metrics) {
|
|
if (!metrics || typeof metrics !== "object") {
|
|
metricsDialogVisual.innerHTML = "<div class='metrics-empty'>No execution metrics available yet.</div>";
|
|
return;
|
|
}
|
|
|
|
const timing = (metrics.timingBreakdown && typeof metrics.timingBreakdown === "object") ? metrics.timingBreakdown : {};
|
|
const autoExplain = (metrics.autoExplain && typeof metrics.autoExplain === "object") ? metrics.autoExplain : {};
|
|
const logicalStatementCount = statementCountFromMetrics(metrics) ?? 0;
|
|
const explainedStatementCount = readFiniteNumber(timing.explainedStatementCount) ?? 0;
|
|
const capturedExecutableStatementCount = readFiniteNumber(timing.capturedExecutableStatementCount) ?? Math.min(explainedStatementCount, logicalStatementCount);
|
|
const nestedAutoExplainEntryCount = readFiniteNumber(timing.nestedAutoExplainEntryCount) ?? Math.max(explainedStatementCount - logicalStatementCount, 0);
|
|
const notCapturedCount = readFiniteNumber(timing.notExplainedStatementCount) ?? Math.max(logicalStatementCount - capturedExecutableStatementCount, 0);
|
|
const captureRate = percentage(capturedExecutableStatementCount, logicalStatementCount);
|
|
const totalDurationMs = readFiniteNumber(metrics.durationMs) ?? 0;
|
|
const buildSqlScriptMs = readFiniteNumber(timing.buildSqlScriptMs) ?? 0;
|
|
const buildInstrumentationMs = readFiniteNumber(timing.buildInstrumentationMs) ?? 0;
|
|
const preExecutionSnapshotMs = readFiniteNumber(timing.preExecutionSnapshotMs) ?? 0;
|
|
const executePrimaryMs = readFiniteNumber(timing.executePrimaryMs) ?? 0;
|
|
const executeFallbackMs = readFiniteNumber(timing.executeFallbackMs) ?? 0;
|
|
const postExecutionSnapshotMs = readFiniteNumber(timing.postExecutionSnapshotMs) ?? 0;
|
|
const autoExplainReadParseMs = readFiniteNumber(timing.autoExplainReadParseMs) ?? 0;
|
|
const metricsAssemblyMs = readFiniteNumber(timing.metricsAssemblyMs) ?? 0;
|
|
const preparationMs = readFiniteNumber(timing.preparationMs) ?? 0;
|
|
const statementWallMsTotal = readFiniteNumber(timing.statementWallMsTotal) ?? 0;
|
|
const postProcessingMs = readFiniteNumber(timing.postProcessingMs) ?? 0;
|
|
const uncategorizedMs = readFiniteNumber(timing.uncategorizedMs) ?? 0;
|
|
const planningMs = readFiniteNumber(timing.totalPlanningMs) ?? 0;
|
|
const executionMs = readFiniteNumber(timing.totalExecutionMs) ?? 0;
|
|
const autoExplainDurationMs = readFiniteNumber(timing.totalAutoExplainDurationMs) ?? 0;
|
|
const plannerExecutorMs = Math.max(0, planningMs + executionMs);
|
|
const capturedNonPlannerExecutionMs = readFiniteNumber(timing.capturedNonPlannerExecutionMs) ?? Math.max(0, autoExplainDurationMs - plannerExecutorMs);
|
|
const uncapturedExecutionMs = readFiniteNumber(timing.uncapturedExecutionMs) ?? Math.max(0, statementWallMsTotal - autoExplainDurationMs);
|
|
const otherExecutionOverheadMs = Math.max(0, capturedNonPlannerExecutionMs + uncapturedExecutionMs);
|
|
const parsedEntryCount = readFiniteNumber(autoExplain.parsedEntryCount) ?? 0;
|
|
const parseErrorCount = readFiniteNumber(autoExplain.parseErrorCount) ?? 0;
|
|
const topRefs = Array.isArray(metrics.topTableReferences) ? metrics.topTableReferences.slice(0, 5) : [];
|
|
const slowestStatements = Array.isArray(metrics.slowestStatements) ? metrics.slowestStatements.slice(0, 5) : [];
|
|
const rowChangeDiagnostics = Array.isArray(metrics.rowChangeDiagnostics) ? metrics.rowChangeDiagnostics.slice(0, 8) : [];
|
|
const maxTopRefCount = topRefs.reduce((maxValue, entry) => Math.max(maxValue, readFiniteNumber(entry?.statementReferences) ?? 0), 0);
|
|
const maxSlowStatementMs = slowestStatements.reduce((maxValue, entry) => Math.max(maxValue, readFiniteNumber(entry?.wallMs) ?? 0), 0);
|
|
const maxChangedRows = rowChangeDiagnostics.reduce((maxValue, entry) => Math.max(maxValue, readFiniteNumber(entry?.changedRows) ?? 0), 0);
|
|
|
|
const captureTone = captureRate >= 80 ? "good" : (captureRate >= 40 ? "warn" : "danger");
|
|
const parseTone = parseErrorCount > 0 ? "danger" : "good";
|
|
const markerTone = autoExplain.markerFound === true ? "good" : "warn";
|
|
const setupTone = autoExplain.enabled === true ? "good" : "warn";
|
|
const nestedTone = nestedAutoExplainEntryCount > 0 ? "warn" : "good";
|
|
|
|
const topRefsMarkup = topRefs.length === 0
|
|
? "<div class='metrics-empty'>No table-reference distribution collected.</div>"
|
|
: topRefs.map((entry) => {
|
|
const tableId = typeof entry?.tableId === "string" ? entry.tableId : "unknown-table";
|
|
const referenceCount = readFiniteNumber(entry?.statementReferences) ?? 0;
|
|
return renderMetricsBarRow(
|
|
tableId,
|
|
String(referenceCount) + " refs",
|
|
percentage(referenceCount, maxTopRefCount),
|
|
"warn",
|
|
);
|
|
}).join("");
|
|
|
|
const slowestMarkup = slowestStatements.length === 0
|
|
? "<div class='metrics-empty'>No auto-explain statement entries parsed yet.</div>"
|
|
: slowestStatements.map((entry) => {
|
|
const statementIndex = readFiniteNumber(entry?.index);
|
|
const wallMs = readFiniteNumber(entry?.wallMs) ?? 0;
|
|
const kind = typeof entry?.kind === "string" ? entry.kind : "UNKNOWN";
|
|
const label = "#" + (statementIndex == null ? "?" : String(statementIndex)) + " " + kind;
|
|
return renderMetricsBarRow(label, formatNumericValue(wallMs, 1) + "ms", percentage(wallMs, maxSlowStatementMs), "danger");
|
|
}).join("");
|
|
|
|
const rowChangesMarkup = rowChangeDiagnostics.length === 0
|
|
? "<div class='metrics-empty'>No propagated-row diagnostics captured.</div>"
|
|
: rowChangeDiagnostics.map((entry) => {
|
|
const tableId = typeof entry?.tableId === "string" ? entry.tableId : "unknown-table";
|
|
const changedRows = readFiniteNumber(entry?.changedRows);
|
|
const capturedStatementCount = readFiniteNumber(entry?.capturedStatementCount);
|
|
const expectedStatementCount = readFiniteNumber(entry?.expectedStatementCount);
|
|
const coverageSuffix = expectedStatementCount == null || expectedStatementCount <= 0
|
|
? ""
|
|
: " (" + (capturedStatementCount == null ? "?" : String(capturedStatementCount))
|
|
+ "/" + String(expectedStatementCount) + ")";
|
|
if (changedRows == null) {
|
|
return renderMetricsBarRow(tableId, "n/a" + coverageSuffix, 0, "warn");
|
|
}
|
|
return renderMetricsBarRow(tableId, String(changedRows) + " rows" + coverageSuffix, percentage(changedRows, maxChangedRows), "good");
|
|
}).join("");
|
|
|
|
const timelineTotalMs = Math.max(totalDurationMs, 1);
|
|
const timelineTickMarkup = new Array(11)
|
|
.fill(null)
|
|
.map((_, index) => {
|
|
const ratio = index / 10;
|
|
return ""
|
|
+ "<div class='metrics-timeline-tick' data-metrics-timeline-tick-ratio='" + ratio.toFixed(4) + "'></div>"
|
|
+ "<div class='metrics-timeline-tick-label' data-metrics-timeline-tick-ratio='" + ratio.toFixed(4) + "'>" + formatNumericValue(timelineTotalMs * ratio, 0) + "ms</div>";
|
|
})
|
|
.join("");
|
|
const lifecycleSegments = [];
|
|
let lifecycleCursorMs = 0;
|
|
const pushLifecycleSegment = (segment) => {
|
|
if ((segment.durationMs ?? 0) <= 0.05) return;
|
|
lifecycleSegments.push({
|
|
...segment,
|
|
startMs: lifecycleCursorMs,
|
|
});
|
|
lifecycleCursorMs += segment.durationMs;
|
|
};
|
|
pushLifecycleSegment({
|
|
label: "Build SQL " + formatNumericValue(buildSqlScriptMs, 1) + "ms",
|
|
durationMs: buildSqlScriptMs,
|
|
className: "",
|
|
title: "Render executable SQL transaction script from table statements.",
|
|
});
|
|
pushLifecycleSegment({
|
|
label: "Inject auto_explain " + formatNumericValue(buildInstrumentationMs, 1) + "ms",
|
|
durationMs: buildInstrumentationMs,
|
|
className: "",
|
|
title: "Add auto_explain setup and log markers around transaction script.",
|
|
});
|
|
pushLifecycleSegment({
|
|
label: "Snapshot before " + formatNumericValue(preExecutionSnapshotMs, 1) + "ms",
|
|
durationMs: preExecutionSnapshotMs,
|
|
className: "",
|
|
title: "Read current PostgreSQL logfile path/size before executing SQL.",
|
|
});
|
|
pushLifecycleSegment({
|
|
label: "Execute (primary) " + formatNumericValue(executePrimaryMs, 1) + "ms",
|
|
durationMs: executePrimaryMs,
|
|
className: "exec",
|
|
title: "Primary SQL execution call (instrumented with auto_explain).",
|
|
});
|
|
pushLifecycleSegment({
|
|
label: "Execute (fallback) " + formatNumericValue(executeFallbackMs, 1) + "ms",
|
|
durationMs: executeFallbackMs,
|
|
className: "exec",
|
|
title: "Fallback SQL execution without auto_explain if setup fails.",
|
|
});
|
|
pushLifecycleSegment({
|
|
label: "Snapshot after " + formatNumericValue(postExecutionSnapshotMs, 1) + "ms",
|
|
durationMs: postExecutionSnapshotMs,
|
|
className: "post",
|
|
title: "Read PostgreSQL logfile path/size after SQL execution.",
|
|
});
|
|
pushLifecycleSegment({
|
|
label: "Read+parse logs " + formatNumericValue(autoExplainReadParseMs, 1) + "ms",
|
|
durationMs: autoExplainReadParseMs,
|
|
className: "post",
|
|
title: "Read logfile chunks, find markers, parse auto_explain JSON entries.",
|
|
});
|
|
pushLifecycleSegment({
|
|
label: "Assemble metrics " + formatNumericValue(metricsAssemblyMs, 1) + "ms",
|
|
durationMs: metricsAssemblyMs,
|
|
className: "post",
|
|
title: "Aggregate timings/table refs/row deltas and build response payload.",
|
|
});
|
|
pushLifecycleSegment({
|
|
label: "Untracked " + formatNumericValue(uncategorizedMs, 1) + "ms",
|
|
durationMs: uncategorizedMs,
|
|
className: "untracked",
|
|
title: "Remainder from timing precision and small unbucketed operations.",
|
|
});
|
|
|
|
const executionCompositionSegments = [];
|
|
let executionCompositionCursorMs = preparationMs;
|
|
const pushExecutionCompositionSegment = (segment) => {
|
|
if ((segment.durationMs ?? 0) <= 0.05) return;
|
|
executionCompositionSegments.push({
|
|
...segment,
|
|
startMs: executionCompositionCursorMs,
|
|
});
|
|
executionCompositionCursorMs += segment.durationMs;
|
|
};
|
|
pushExecutionCompositionSegment({
|
|
label: "Planner+Executor " + formatNumericValue(plannerExecutorMs, 1) + "ms",
|
|
durationMs: plannerExecutorMs,
|
|
className: "planexec",
|
|
title: "Planning + execution time reported by auto_explain entries.",
|
|
});
|
|
pushExecutionCompositionSegment({
|
|
label: "Captured non-plan time " + formatNumericValue(capturedNonPlannerExecutionMs, 1) + "ms",
|
|
durationMs: capturedNonPlannerExecutionMs,
|
|
className: "warn",
|
|
title: "Captured SQL duration not attributed to planner/executor fields.",
|
|
});
|
|
pushExecutionCompositionSegment({
|
|
label: "Uncaptured SQL wall " + formatNumericValue(uncapturedExecutionMs, 1) + "ms",
|
|
durationMs: uncapturedExecutionMs,
|
|
className: "exec",
|
|
title: "Execution wall time outside captured auto_explain durations.",
|
|
});
|
|
const toTimelineSegmentsMarkup = (segments) => segments
|
|
.filter((segment) => (segment.durationMs ?? 0) > 0.05)
|
|
.map((segment) => {
|
|
return "<div class='metrics-timeline-segment " + escapeHtml(segment.className) + "' data-metrics-timeline-segment data-start-ms='" + String(segment.startMs) + "' data-duration-ms='" + String(segment.durationMs) + "' title='" + escapeHtml(segment.title) + "'>"
|
|
+ escapeHtml(segment.label)
|
|
+ "</div>";
|
|
})
|
|
.join("");
|
|
const timelineZoom = Number.isFinite(state.metricsTimelineZoom) ? state.metricsTimelineZoom : 1.5;
|
|
const timelineMarkup = ""
|
|
+ "<div class='metrics-card'>"
|
|
+ "<div class='metrics-card-title'>Timeline</div>"
|
|
+ "<div class='metrics-timeline-shell'>"
|
|
+ "<div class='metrics-timeline-controls'>"
|
|
+ "<span>zoom</span>"
|
|
+ "<input type='range' min='0.5' max='12' step='0.1' value='" + String(timelineZoom) + "' data-metrics-timeline-zoom-slider />"
|
|
+ "<span data-metrics-timeline-zoom-value>" + timelineZoom.toFixed(1) + "x</span>"
|
|
+ "</div>"
|
|
+ "<div class='metrics-timeline-scroll'>"
|
|
+ "<div class='metrics-timeline-track' data-metrics-timeline-track data-total-ms='" + String(timelineTotalMs) + "'>"
|
|
+ "<div class='metrics-timeline-ruler'>" + timelineTickMarkup + "</div>"
|
|
+ "<div class='metrics-timeline-lane-label'>Lifecycle (chronological)</div>"
|
|
+ "<div class='metrics-timeline-lane'>" + toTimelineSegmentsMarkup(lifecycleSegments) + "</div>"
|
|
+ "<div class='metrics-timeline-lane-label'>Execute SQL breakdown</div>"
|
|
+ "<div class='metrics-timeline-lane'>" + toTimelineSegmentsMarkup(executionCompositionSegments) + "</div>"
|
|
+ "</div>"
|
|
+ "</div>"
|
|
+ "<div class='metrics-timeline-note'>Lane 1 is chronological and breaks prep/post into concrete measured operations. Lane 2 decomposes SQL wall time into planner+executor, captured non-plan time, and uncaptured SQL wall.</div>"
|
|
+ "</div>"
|
|
+ "</div>";
|
|
|
|
metricsDialogVisual.innerHTML = ""
|
|
+ "<div class='metrics-grid'>"
|
|
+ "<div class='metrics-card'>"
|
|
+ "<div class='metrics-card-title'>Capture Health</div>"
|
|
+ "<div class='metrics-big-value'>" + formatNumericValue(captureRate, 1) + "%</div>"
|
|
+ renderMetricsBarRow("Captured executable statements", String(capturedExecutableStatementCount) + "/" + String(logicalStatementCount), captureRate, captureTone)
|
|
+ renderMetricsBarRow("Captured entries (nested)", String(explainedStatementCount), 100, nestedTone)
|
|
+ renderMetricsBarRow("Nested entry overage", String(nestedAutoExplainEntryCount), nestedAutoExplainEntryCount > 0 ? 100 : 0, nestedTone)
|
|
+ renderMetricsBarRow("Missing estimate", String(notCapturedCount), percentage(notCapturedCount, logicalStatementCount), "warn")
|
|
+ renderMetricsBarRow("Marker found", autoExplain.markerFound === true ? "yes" : "no", 100, markerTone)
|
|
+ "</div>"
|
|
+ "<div class='metrics-card'>"
|
|
+ "<div class='metrics-card-title'>Timing Composition</div>"
|
|
+ "<div class='metrics-big-value'>" + formatNumericValue(totalDurationMs, 1) + "ms</div>"
|
|
+ renderMetricsBarRow("Statement wall total", formatNumericValue(statementWallMsTotal, 1) + "ms", percentage(statementWallMsTotal, totalDurationMs), "good")
|
|
+ renderMetricsBarRow("Planner + execution", formatNumericValue(plannerExecutorMs, 1) + "ms", percentage(plannerExecutorMs, totalDurationMs), "danger")
|
|
+ renderMetricsBarRow("Captured non-plan execution", formatNumericValue(capturedNonPlannerExecutionMs, 1) + "ms", percentage(capturedNonPlannerExecutionMs, totalDurationMs), "warn")
|
|
+ renderMetricsBarRow("Uncaptured SQL wall", formatNumericValue(uncapturedExecutionMs, 1) + "ms", percentage(uncapturedExecutionMs, totalDurationMs), "good")
|
|
+ renderMetricsBarRow("Other execution overhead", formatNumericValue(otherExecutionOverheadMs, 1) + "ms", percentage(otherExecutionOverheadMs, totalDurationMs), "warn")
|
|
+ renderMetricsBarRow("auto_explain duration", formatNumericValue(autoExplainDurationMs, 1) + "ms", percentage(autoExplainDurationMs, totalDurationMs), "good")
|
|
+ "</div>"
|
|
+ "<div class='metrics-card'>"
|
|
+ "<div class='metrics-card-title'>Parser Status</div>"
|
|
+ "<div class='metrics-big-value'>" + String(parsedEntryCount) + " entries</div>"
|
|
+ renderMetricsBarRow("Capture enabled", autoExplain.enabled === true ? "yes" : "no", 100, setupTone)
|
|
+ renderMetricsBarRow("Parse errors", String(parseErrorCount), parseErrorCount > 0 ? 100 : 0, parseTone)
|
|
+ renderMetricsBarRow("Log bytes read", String(readFiniteNumber(autoExplain.logReadBytes) ?? 0), 100, "good")
|
|
+ "</div>"
|
|
+ "</div>"
|
|
+ "<div class='metrics-grid'>"
|
|
+ "<div class='metrics-card'><div class='metrics-card-title'>Top Referenced Tables</div>" + topRefsMarkup + "</div>"
|
|
+ "<div class='metrics-card'><div class='metrics-card-title'>Slowest Parsed Statements</div>" + slowestMarkup + "</div>"
|
|
+ "<div class='metrics-card'><div class='metrics-card-title'>Rows Changed By Table</div>" + rowChangesMarkup + "</div>"
|
|
+ "</div>"
|
|
+ timelineMarkup
|
|
+ "<div class='metrics-card'>"
|
|
+ "<div class='metrics-card-title'>What This Captures</div>"
|
|
+ "<ul class='metrics-list'>"
|
|
+ "<li><strong>Wall clock totals</strong>: end-to-end transaction time and cumulative SQL statement wall time.</li>"
|
|
+ "<li><strong>auto_explain timing</strong>: planner and executor time from PostgreSQL for statements that are actually captured.</li>"
|
|
+ "<li><strong>Captured entries can exceed executable statements</strong>: nested plans/logged sub-statements make entry counts larger than generated statement count.</li>"
|
|
+ "<li><strong>Plan-level IO stats</strong>: shared hits/reads, temp writes, WAL bytes, node type, and actual rows for slow statements.</li>"
|
|
+ "<li><strong>Coverage signals</strong>: marker detection, parsed entry count, and estimated uncaptured statements.</li>"
|
|
+ "<li><strong>Schema pressure hints</strong>: statement-reference frequency per table to identify hotspots in dependency graphs.</li>"
|
|
+ "</ul>"
|
|
+ "</div>";
|
|
const timelineSlider = metricsDialogVisual.querySelector("[data-metrics-timeline-zoom-slider]");
|
|
if (timelineSlider instanceof HTMLInputElement) {
|
|
timelineSlider.oninput = () => {
|
|
const nextZoom = Number(timelineSlider.value);
|
|
state.metricsTimelineZoom = Number.isFinite(nextZoom) ? nextZoom : 1.5;
|
|
layoutMetricsTimeline(metricsDialogVisual, state.metricsTimelineZoom);
|
|
};
|
|
}
|
|
layoutMetricsTimeline(metricsDialogVisual, timelineZoom);
|
|
}
|
|
|
|
function mutationDetailLines(metrics) {
|
|
if (!metrics || typeof metrics !== "object") {
|
|
return "No execution details available.";
|
|
}
|
|
const timing = (metrics.timingBreakdown && typeof metrics.timingBreakdown === "object") ? metrics.timingBreakdown : null;
|
|
const autoExplain = (metrics.autoExplain && typeof metrics.autoExplain === "object") ? metrics.autoExplain : null;
|
|
const topRefs = Array.isArray(metrics.topTableReferences) ? metrics.topTableReferences : [];
|
|
const firstPreviews = Array.isArray(metrics.firstStatementPreviews) ? metrics.firstStatementPreviews : [];
|
|
const lastPreviews = Array.isArray(metrics.lastStatementPreviews) ? metrics.lastStatementPreviews : [];
|
|
const slowestStatements = Array.isArray(metrics.slowestStatements) ? metrics.slowestStatements : [];
|
|
const rowChangeDiagnostics = Array.isArray(metrics.rowChangeDiagnostics) ? metrics.rowChangeDiagnostics : [];
|
|
const lines = [
|
|
"Execution summary",
|
|
"durationMs: " + (readFiniteNumber(metrics.durationMs) ?? "n/a"),
|
|
"logicalStatementCount: " + (statementCountFromMetrics(metrics) ?? "n/a"),
|
|
"executableStatementCount: " + (executableStatementCountFromMetrics(metrics) ?? "n/a"),
|
|
"sequentialStatementCount: " + (readFiniteNumber(metrics.sequentialStatementCount) ?? "n/a"),
|
|
"uniqueTableReferenceCount: " + (readFiniteNumber(metrics.uniqueTableReferenceCount) ?? "n/a"),
|
|
"sqlScriptLengthChars: " + (readFiniteNumber(metrics.sqlScriptLength) ?? "n/a"),
|
|
"buildSqlScriptMs: " + (readFiniteNumber(timing?.buildSqlScriptMs) ?? "n/a"),
|
|
"buildInstrumentationMs: " + (readFiniteNumber(timing?.buildInstrumentationMs) ?? "n/a"),
|
|
"preExecutionSnapshotMs: " + (readFiniteNumber(timing?.preExecutionSnapshotMs) ?? "n/a"),
|
|
"preparationMs: " + (readFiniteNumber(timing?.preparationMs) ?? "n/a"),
|
|
"executePrimaryMs: " + (readFiniteNumber(timing?.executePrimaryMs) ?? "n/a"),
|
|
"executeFallbackMs: " + (readFiniteNumber(timing?.executeFallbackMs) ?? "n/a"),
|
|
"statementWallMsTotal: " + (readFiniteNumber(timing?.statementWallMsTotal) ?? "n/a"),
|
|
"postExecutionSnapshotMs: " + (readFiniteNumber(timing?.postExecutionSnapshotMs) ?? "n/a"),
|
|
"autoExplainReadParseMs: " + (readFiniteNumber(timing?.autoExplainReadParseMs) ?? "n/a"),
|
|
"metricsAssemblyMs: " + (readFiniteNumber(timing?.metricsAssemblyMs) ?? "n/a"),
|
|
"postProcessingMs: " + (readFiniteNumber(timing?.postProcessingMs) ?? "n/a"),
|
|
"uncategorizedMs: " + (readFiniteNumber(timing?.uncategorizedMs) ?? "n/a"),
|
|
"totalAutoExplainDurationMs: " + (readFiniteNumber(timing?.totalAutoExplainDurationMs) ?? "n/a"),
|
|
"totalPlanningMs(auto_explain): " + (readFiniteNumber(timing?.totalPlanningMs) ?? "n/a"),
|
|
"totalExecutionMs(auto_explain): " + (readFiniteNumber(timing?.totalExecutionMs) ?? "n/a"),
|
|
"capturedNonPlannerExecutionMs: " + (readFiniteNumber(timing?.capturedNonPlannerExecutionMs) ?? "n/a"),
|
|
"uncapturedExecutionMs: " + (readFiniteNumber(timing?.uncapturedExecutionMs) ?? "n/a"),
|
|
"capturedExecutableStatementCount: " + (readFiniteNumber(timing?.capturedExecutableStatementCount) ?? "n/a"),
|
|
"autoExplainEntryCount: " + (readFiniteNumber(timing?.explainedStatementCount) ?? "n/a"),
|
|
"nestedAutoExplainEntryCount: " + (readFiniteNumber(timing?.nestedAutoExplainEntryCount) ?? "n/a"),
|
|
"notCapturedStatementEstimate: " + (readFiniteNumber(timing?.notExplainedStatementCount) ?? "n/a"),
|
|
"",
|
|
"Top referenced tables",
|
|
];
|
|
if (topRefs.length === 0) {
|
|
lines.push("(none)");
|
|
} else {
|
|
for (const entry of topRefs) {
|
|
const tableId = typeof entry?.tableId === "string" ? entry.tableId : "unknown-table";
|
|
const count = readFiniteNumber(entry?.statementReferences);
|
|
lines.push("- " + tableId + ": " + (count ?? "?") + " statement references");
|
|
}
|
|
}
|
|
|
|
lines.push("", "Auto-explain capture");
|
|
lines.push("enabled: " + (autoExplain?.enabled === true ? "yes" : "no"));
|
|
lines.push("setupError: " + (typeof autoExplain?.setupError === "string" ? autoExplain.setupError : "none"));
|
|
lines.push("logReadError: " + (typeof autoExplain?.logReadError === "string" ? autoExplain.logReadError : "none"));
|
|
lines.push("logPath: " + (typeof autoExplain?.logPath === "string" ? autoExplain.logPath : "n/a"));
|
|
lines.push("logReadBytes: " + (readFiniteNumber(autoExplain?.logReadBytes) ?? "n/a"));
|
|
lines.push("markerFound: " + (autoExplain?.markerFound === true ? "yes" : "no"));
|
|
lines.push("parsedEntryCount: " + (readFiniteNumber(autoExplain?.parsedEntryCount) ?? "n/a"));
|
|
lines.push("parseErrorCount: " + (readFiniteNumber(autoExplain?.parseErrorCount) ?? "n/a"));
|
|
|
|
lines.push("", "Rows changed by table");
|
|
if (rowChangeDiagnostics.length === 0) {
|
|
lines.push("(none)");
|
|
} else {
|
|
for (const entry of rowChangeDiagnostics) {
|
|
const tableId = typeof entry?.tableId === "string" ? entry.tableId : "unknown-table";
|
|
const changedRows = readFiniteNumber(entry?.changedRows);
|
|
const capturedStatementCount = readFiniteNumber(entry?.capturedStatementCount);
|
|
const expectedStatementCount = readFiniteNumber(entry?.expectedStatementCount);
|
|
const coverageSuffix = expectedStatementCount == null || expectedStatementCount <= 0
|
|
? ""
|
|
: " (captured " + (capturedStatementCount == null ? "?" : String(capturedStatementCount))
|
|
+ "/" + String(expectedStatementCount) + " statements)";
|
|
lines.push(
|
|
"- " + tableId + ": "
|
|
+ (changedRows == null ? "n/a (not captured)" : String(changedRows) + " changed rows")
|
|
+ coverageSuffix,
|
|
);
|
|
}
|
|
}
|
|
|
|
lines.push("", "Slowest executable statements");
|
|
if (slowestStatements.length === 0) {
|
|
lines.push("(none)");
|
|
} else {
|
|
for (const statement of slowestStatements) {
|
|
const wallMs = readFiniteNumber(statement?.wallMs);
|
|
const planningMs = readFiniteNumber(statement?.planningMs);
|
|
const executionMs = readFiniteNumber(statement?.executionMs);
|
|
const kind = typeof statement?.kind === "string" ? statement.kind : "UNKNOWN";
|
|
const index = readFiniteNumber(statement?.index);
|
|
const rootNodeType = typeof statement?.rootNodeType === "string" ? statement.rootNodeType : "n/a";
|
|
const actualRows = readFiniteNumber(statement?.actualRows);
|
|
const sharedHits = readFiniteNumber(statement?.sharedHitBlocks);
|
|
const sharedReads = readFiniteNumber(statement?.sharedReadBlocks);
|
|
const tempWrites = readFiniteNumber(statement?.tempWrittenBlocks);
|
|
const walBytes = readFiniteNumber(statement?.walBytes);
|
|
lines.push(
|
|
"#" + (index == null ? "?" : String(index))
|
|
+ " kind=" + kind
|
|
+ " wallMs=" + (wallMs == null ? "?" : String(wallMs))
|
|
+ " planningMs=" + (planningMs == null ? "n/a" : String(planningMs))
|
|
+ " executionMs=" + (executionMs == null ? "n/a" : String(executionMs))
|
|
+ " node=" + rootNodeType
|
|
+ " actualRows=" + (actualRows == null ? "n/a" : String(actualRows))
|
|
+ " sharedHit=" + (sharedHits == null ? "n/a" : String(sharedHits))
|
|
+ " sharedRead=" + (sharedReads == null ? "n/a" : String(sharedReads))
|
|
+ " tempWritten=" + (tempWrites == null ? "n/a" : String(tempWrites))
|
|
+ " walBytes=" + (walBytes == null ? "n/a" : String(walBytes))
|
|
);
|
|
lines.push(typeof statement?.sqlPreview === "string" ? statement.sqlPreview : "(missing sql preview)");
|
|
lines.push("");
|
|
}
|
|
}
|
|
|
|
lines.push("", "Auto-explain log excerpt");
|
|
lines.push(typeof autoExplain?.rawLogExcerpt === "string" ? autoExplain.rawLogExcerpt : "(none)");
|
|
|
|
lines.push("", "First generated statements");
|
|
if (firstPreviews.length === 0) {
|
|
lines.push("(none)");
|
|
} else {
|
|
for (const preview of firstPreviews) {
|
|
lines.push("#" + (preview.index ?? "?") + " (" + (preview.outputName ?? "no outputName") + ")");
|
|
lines.push(typeof preview.sqlPreview === "string" ? preview.sqlPreview : "(missing sql preview)");
|
|
lines.push("");
|
|
}
|
|
}
|
|
|
|
lines.push("Last generated statements");
|
|
if (lastPreviews.length === 0) {
|
|
lines.push("(none)");
|
|
} else {
|
|
for (const preview of lastPreviews) {
|
|
lines.push("#" + (preview.index ?? "?") + " (" + (preview.outputName ?? "no outputName") + ")");
|
|
lines.push(typeof preview.sqlPreview === "string" ? preview.sqlPreview : "(missing sql preview)");
|
|
lines.push("");
|
|
}
|
|
}
|
|
|
|
lines.push("Executed SQL script");
|
|
lines.push(typeof metrics.sqlScript === "string" ? metrics.sqlScript : "(missing sql script)");
|
|
return lines.join("\\n");
|
|
}
|
|
|
|
function showMetricsDialog(actionLabel, tableId, metrics) {
|
|
const durationMs = readMutationDurationMs(metrics);
|
|
const statementCount = statementCountFromMetrics(metrics);
|
|
const executableStatementCount = executableStatementCountFromMetrics(metrics);
|
|
const timing = (metrics && typeof metrics === "object" && metrics.timingBreakdown && typeof metrics.timingBreakdown === "object")
|
|
? metrics.timingBreakdown
|
|
: null;
|
|
metricsDialogTitle.textContent = actionLabel + " • " + tableId;
|
|
metricsDialogMeta.textContent = [
|
|
"duration=" + (durationMs == null ? "n/a" : formatDuration(durationMs)),
|
|
"logicalStatements=" + (statementCount == null ? "n/a" : String(statementCount)),
|
|
"executableStatements=" + (executableStatementCount == null ? "n/a" : String(executableStatementCount)),
|
|
"autoExplainPlanMs=" + (readFiniteNumber(timing?.totalPlanningMs) ?? "n/a"),
|
|
"autoExplainExecMs=" + (readFiniteNumber(timing?.totalExecutionMs) ?? "n/a"),
|
|
].join(" • ");
|
|
renderMetricsVisual(metrics);
|
|
metricsDialogText.textContent = mutationDetailLines(metrics);
|
|
if (metricsDialog.open) {
|
|
metricsDialog.close();
|
|
}
|
|
metricsDialog.showModal();
|
|
}
|
|
|
|
async function runUiAction(label, fn) {
|
|
try {
|
|
await fn();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
showErrorDialog(label + ": " + message);
|
|
}
|
|
}
|
|
|
|
async function runButtonAction(button, loadingText, fn) {
|
|
const originalLabel = button.textContent;
|
|
const wasDisabled = button.disabled;
|
|
button.disabled = true;
|
|
button.textContent = loadingText;
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
button.disabled = wasDisabled;
|
|
button.textContent = originalLabel;
|
|
}
|
|
}
|
|
|
|
function formatDuration(durationMs) {
|
|
if (!Number.isFinite(durationMs)) return "n/a";
|
|
if (durationMs < 1000) return durationMs.toFixed(1) + "ms";
|
|
return (durationMs / 1000).toFixed(2) + "s";
|
|
}
|
|
|
|
function formatMutationMetrics(metrics) {
|
|
if (!metrics || typeof metrics !== "object") return "";
|
|
const durationMs = readMutationDurationMs(metrics);
|
|
const statementCount = statementCountFromMetrics(metrics);
|
|
const executableStatementCount = executableStatementCountFromMetrics(metrics);
|
|
const timing = (metrics.timingBreakdown && typeof metrics.timingBreakdown === "object") ? metrics.timingBreakdown : null;
|
|
const planningMs = readFiniteNumber(timing?.totalPlanningMs);
|
|
const executionMs = readFiniteNumber(timing?.totalExecutionMs);
|
|
const autoExplainDurationMs = readFiniteNumber(timing?.totalAutoExplainDurationMs);
|
|
const topRefs = Array.isArray(metrics.topTableReferences) ? metrics.topTableReferences : [];
|
|
const rowChangeDiagnostics = Array.isArray(metrics.rowChangeDiagnostics) ? metrics.rowChangeDiagnostics : [];
|
|
const topSummary = topRefs
|
|
.slice(0, 3)
|
|
.map((entry) => {
|
|
const tableId = typeof entry?.tableId === "string" ? entry.tableId : "unknown-table";
|
|
const count = readFiniteNumber(entry?.statementReferences);
|
|
return tableId + ":" + (count == null ? "?" : String(count));
|
|
})
|
|
.join(", ");
|
|
const rowChangeSummary = rowChangeDiagnostics
|
|
.slice(0, 3)
|
|
.map((entry) => {
|
|
const tableId = typeof entry?.tableId === "string" ? entry.tableId : "unknown-table";
|
|
const changedRows = readFiniteNumber(entry?.changedRows);
|
|
return tableId + ":" + (changedRows == null ? "n/a" : String(changedRows));
|
|
})
|
|
.join(", ");
|
|
const statementLabel = statementCount == null ? "?" : String(statementCount);
|
|
const executableSuffix = executableStatementCount == null ? "" : " • " + executableStatementCount + " executable";
|
|
const planExecSuffix = (planningMs == null && executionMs == null)
|
|
? ""
|
|
: " • auto-explain plan/exec=" + (planningMs == null ? "n/a" : String(planningMs)) + "/" + (executionMs == null ? "n/a" : String(executionMs)) + "ms";
|
|
const durationSuffix = autoExplainDurationMs == null
|
|
? ""
|
|
: " • auto-explain duration=" + autoExplainDurationMs + "ms";
|
|
const rowChangeSuffix = rowChangeSummary === ""
|
|
? ""
|
|
: " • changed rows: " + rowChangeSummary;
|
|
const base = formatDuration(durationMs ?? Number.NaN) + " • " + statementLabel + " logical" + executableSuffix + planExecSuffix + durationSuffix;
|
|
const withTopRefs = topSummary ? base + " • top refs: " + topSummary : base;
|
|
return withTopRefs + rowChangeSuffix;
|
|
}
|
|
|
|
function sortJsonValue(value) {
|
|
if (Array.isArray(value)) {
|
|
return value.map(sortJsonValue);
|
|
}
|
|
if (value && typeof value === "object") {
|
|
const result = {};
|
|
const keys = Object.keys(value).sort();
|
|
for (const key of keys) {
|
|
result[key] = sortJsonValue(value[key]);
|
|
}
|
|
return result;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function prettyJson(value) {
|
|
return JSON.stringify(sortJsonValue(value), null, 2);
|
|
}
|
|
|
|
function compareStrings(a, b) {
|
|
if (a === b) return 0;
|
|
return a < b ? -1 : 1;
|
|
}
|
|
function compareNumbers(a, b) {
|
|
if (a === b) return 0;
|
|
return a < b ? -1 : 1;
|
|
}
|
|
|
|
async function fetchJson(path, init) {
|
|
const normalizedInit = init ? { ...init } : {};
|
|
const method = typeof normalizedInit.method === "string" ? normalizedInit.method.toUpperCase() : "GET";
|
|
const headers = new Headers(normalizedInit.headers || {});
|
|
if (method !== "GET") {
|
|
headers.set("${STUDIO_AUTH_HEADER}", STUDIO_AUTH_TOKEN);
|
|
}
|
|
normalizedInit.headers = headers;
|
|
const response = await fetch(path, normalizedInit);
|
|
let body;
|
|
try {
|
|
body = await response.json();
|
|
} catch (error) {
|
|
body = { error: response.status + " " + response.statusText };
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(body.error || (response.status + " " + response.statusText));
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function computeDepth(tableId, tableMap, cache, visiting) {
|
|
if (cache.has(tableId)) return cache.get(tableId);
|
|
if (visiting.has(tableId)) return 0;
|
|
visiting.add(tableId);
|
|
const table = tableMap.get(tableId);
|
|
if (!table || !Array.isArray(table.dependencies) || table.dependencies.length === 0) {
|
|
cache.set(tableId, 0);
|
|
visiting.delete(tableId);
|
|
return 0;
|
|
}
|
|
let depth = 0;
|
|
for (const dependencyId of table.dependencies) {
|
|
const dependencyDepth = computeDepth(dependencyId, tableMap, cache, visiting);
|
|
if (dependencyDepth + 1 > depth) {
|
|
depth = dependencyDepth + 1;
|
|
}
|
|
}
|
|
cache.set(tableId, depth);
|
|
visiting.delete(tableId);
|
|
return depth;
|
|
}
|
|
function getAverageOrderValue(ids, orderMap, fallback) {
|
|
const values = ids
|
|
.map((id) => orderMap.get(id))
|
|
.filter((value) => typeof value === "number");
|
|
if (values.length === 0) return fallback;
|
|
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
}
|
|
function getNodePosition(tableId) {
|
|
const stored = state.manualNodePositions[tableId];
|
|
if (!stored || typeof stored !== "object") return null;
|
|
const x = Number(stored.x);
|
|
const y = Number(stored.y);
|
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
return {
|
|
x: Math.max(SCENE_MARGIN / 2, x),
|
|
y: Math.max(SCENE_MARGIN / 2, y),
|
|
};
|
|
}
|
|
function pruneNodePositions(tables) {
|
|
const validIds = new Set(tables.map((table) => String(table.id)));
|
|
let changed = false;
|
|
const next = {};
|
|
for (const [tableId, value] of Object.entries(state.manualNodePositions)) {
|
|
if (!validIds.has(tableId)) {
|
|
changed = true;
|
|
continue;
|
|
}
|
|
next[tableId] = value;
|
|
}
|
|
if (!changed) return;
|
|
state.manualNodePositions = next;
|
|
persistNodePositions();
|
|
}
|
|
|
|
function layoutGraph(tables, baseLayout) {
|
|
const tableMap = new Map(tables.map((table) => [table.id, table]));
|
|
const reverseDependencies = new Map();
|
|
const depthCache = new Map();
|
|
const byDepth = new Map();
|
|
|
|
for (const table of tables) {
|
|
reverseDependencies.set(table.id, []);
|
|
}
|
|
for (const table of tables) {
|
|
const dependencies = Array.isArray(table.dependencies) ? table.dependencies : [];
|
|
for (const dependencyId of dependencies) {
|
|
const existing = reverseDependencies.get(dependencyId) ?? [];
|
|
existing.push(table.id);
|
|
reverseDependencies.set(dependencyId, existing);
|
|
}
|
|
}
|
|
|
|
for (const table of tables) {
|
|
const depth = computeDepth(table.id, tableMap, depthCache, new Set());
|
|
if (!byDepth.has(depth)) byDepth.set(depth, []);
|
|
byDepth.get(depth).push(table);
|
|
}
|
|
|
|
const depths = [...byDepth.keys()].sort((a, b) => a - b);
|
|
const positions = new Map();
|
|
let sceneWidth = typeof baseLayout?.sceneWidth === "number" ? baseLayout.sceneWidth : 600;
|
|
let sceneHeight = typeof baseLayout?.sceneHeight === "number" ? baseLayout.sceneHeight : 600;
|
|
const basePositions = baseLayout && typeof baseLayout === "object" && baseLayout.positions && typeof baseLayout.positions === "object"
|
|
? baseLayout.positions
|
|
: null;
|
|
|
|
if (basePositions) {
|
|
for (const table of tables) {
|
|
const fallbackDepth = depthCache.get(table.id) ?? 0;
|
|
const fallbackIndex = (byDepth.get(fallbackDepth) ?? []).findIndex((rowTable) => rowTable.id === table.id);
|
|
const fallbackX = SCENE_MARGIN + Math.max(0, fallbackIndex) * (NODE_WIDTH + COLUMN_GAP_X);
|
|
const fallbackY = SCENE_MARGIN + fallbackDepth * LEVEL_GAP_Y;
|
|
const basePosition = basePositions[table.id];
|
|
const manualPosition = getNodePosition(table.id);
|
|
const x = manualPosition
|
|
? manualPosition.x
|
|
: Number.isFinite(Number(basePosition?.x))
|
|
? Number(basePosition.x)
|
|
: fallbackX;
|
|
const y = manualPosition
|
|
? manualPosition.y
|
|
: Number.isFinite(Number(basePosition?.y))
|
|
? Number(basePosition.y)
|
|
: fallbackY;
|
|
positions.set(table.id, { x, y });
|
|
sceneWidth = Math.max(sceneWidth, x + NODE_WIDTH + SCENE_MARGIN);
|
|
sceneHeight = Math.max(sceneHeight, y + NODE_HEIGHT + SCENE_MARGIN);
|
|
}
|
|
|
|
return {
|
|
positions,
|
|
sceneWidth,
|
|
sceneHeight,
|
|
depthById: depthCache,
|
|
};
|
|
}
|
|
|
|
for (const depth of depths) {
|
|
const row = byDepth.get(depth);
|
|
row.sort((a, b) => compareStrings(String(a.name), String(b.name)));
|
|
}
|
|
|
|
for (let iteration = 0; iteration < 6; iteration++) {
|
|
const orderMap = new Map();
|
|
for (const depth of depths) {
|
|
const row = byDepth.get(depth) ?? [];
|
|
for (let i = 0; i < row.length; i++) {
|
|
orderMap.set(row[i].id, i);
|
|
}
|
|
}
|
|
for (let depthIndex = 1; depthIndex < depths.length; depthIndex++) {
|
|
const depth = depths[depthIndex];
|
|
const row = byDepth.get(depth) ?? [];
|
|
row.sort((a, b) => {
|
|
const aDeps = Array.isArray(a.dependencies) ? a.dependencies : [];
|
|
const bDeps = Array.isArray(b.dependencies) ? b.dependencies : [];
|
|
const aScore = getAverageOrderValue(aDeps, orderMap, Number.MAX_SAFE_INTEGER / 4);
|
|
const bScore = getAverageOrderValue(bDeps, orderMap, Number.MAX_SAFE_INTEGER / 4);
|
|
return compareNumbers(aScore, bScore) || compareStrings(String(a.name), String(b.name));
|
|
});
|
|
}
|
|
|
|
orderMap.clear();
|
|
for (const depth of depths) {
|
|
const row = byDepth.get(depth) ?? [];
|
|
for (let i = 0; i < row.length; i++) {
|
|
orderMap.set(row[i].id, i);
|
|
}
|
|
}
|
|
for (let depthIndex = depths.length - 2; depthIndex >= 0; depthIndex--) {
|
|
const depth = depths[depthIndex];
|
|
const row = byDepth.get(depth) ?? [];
|
|
row.sort((a, b) => {
|
|
const aDependents = reverseDependencies.get(a.id) ?? [];
|
|
const bDependents = reverseDependencies.get(b.id) ?? [];
|
|
const aScore = getAverageOrderValue(aDependents, orderMap, Number.MAX_SAFE_INTEGER / 4);
|
|
const bScore = getAverageOrderValue(bDependents, orderMap, Number.MAX_SAFE_INTEGER / 4);
|
|
return compareNumbers(aScore, bScore) || compareStrings(String(a.name), String(b.name));
|
|
});
|
|
}
|
|
}
|
|
|
|
for (let depthIndex = 0; depthIndex < depths.length; depthIndex++) {
|
|
const depth = depths[depthIndex];
|
|
const row = byDepth.get(depth);
|
|
const totalWidth = row.length * NODE_WIDTH + (row.length - 1) * COLUMN_GAP_X;
|
|
const startX = SCENE_MARGIN + Math.max(0, (900 - totalWidth) / 2);
|
|
const y = SCENE_MARGIN + depthIndex * LEVEL_GAP_Y;
|
|
for (let i = 0; i < row.length; i++) {
|
|
const defaultX = startX + i * (NODE_WIDTH + COLUMN_GAP_X);
|
|
const defaultY = y;
|
|
const manualPosition = getNodePosition(row[i].id);
|
|
const x = manualPosition ? manualPosition.x : defaultX;
|
|
const finalY = manualPosition ? manualPosition.y : defaultY;
|
|
positions.set(row[i].id, { x, y: finalY });
|
|
sceneWidth = Math.max(sceneWidth, x + NODE_WIDTH + SCENE_MARGIN);
|
|
sceneHeight = Math.max(sceneHeight, finalY + NODE_HEIGHT + SCENE_MARGIN);
|
|
}
|
|
}
|
|
|
|
return {
|
|
positions,
|
|
sceneWidth,
|
|
sceneHeight,
|
|
depthById: depthCache,
|
|
};
|
|
}
|
|
function syncSceneDimensions() {
|
|
if (!state.graphLayout) return;
|
|
graphScene.style.width = state.graphLayout.sceneWidth + "px";
|
|
graphScene.style.height = state.graphLayout.sceneHeight + "px";
|
|
graphEdges.setAttribute("width", String(state.graphLayout.sceneWidth));
|
|
graphEdges.setAttribute("height", String(state.graphLayout.sceneHeight));
|
|
graphEdges.setAttribute("viewBox", "0 0 " + state.graphLayout.sceneWidth + " " + state.graphLayout.sceneHeight);
|
|
graphNodes.style.width = state.graphLayout.sceneWidth + "px";
|
|
graphNodes.style.height = state.graphLayout.sceneHeight + "px";
|
|
}
|
|
function getVisibleTableIds() {
|
|
const ids = new Set();
|
|
for (const node of graphNodes.querySelectorAll(".node")) {
|
|
const tid = node.getAttribute("data-table-id");
|
|
if (tid && node.style.display !== "none") ids.add(tid);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
function buildGraphEdges(tables, positions, depthById) {
|
|
const visibleIds = getVisibleTableIds();
|
|
const tableMap = new Map(tables.map((t) => [t.id, t]));
|
|
|
|
function resolveVisibleAncestors(tableId, visited) {
|
|
if (visited.has(tableId)) return [];
|
|
visited.add(tableId);
|
|
if (visibleIds.has(tableId)) return [tableId];
|
|
const table = tableMap.get(tableId);
|
|
if (!table) return [];
|
|
const deps = Array.isArray(table.dependencies) ? table.dependencies : [];
|
|
const results = [];
|
|
for (const depId of deps) {
|
|
results.push(...resolveVisibleAncestors(depId, visited));
|
|
}
|
|
return results;
|
|
}
|
|
|
|
const edges = [];
|
|
const outgoingByNode = new Map();
|
|
const incomingByNode = new Map();
|
|
for (const table of tables) {
|
|
if (!visibleIds.has(table.id)) continue;
|
|
const to = positions.get(table.id);
|
|
if (!to) continue;
|
|
const dependencies = Array.isArray(table.dependencies) ? table.dependencies : [];
|
|
const resolvedDeps = new Set();
|
|
for (const depId of dependencies) {
|
|
for (const resolved of resolveVisibleAncestors(depId, new Set([table.id]))) {
|
|
resolvedDeps.add(resolved);
|
|
}
|
|
}
|
|
for (const dependencyId of resolvedDeps) {
|
|
const from = positions.get(dependencyId);
|
|
if (!from) continue;
|
|
const edge = {
|
|
id: dependencyId + "->" + table.id,
|
|
fromId: dependencyId,
|
|
toId: table.id,
|
|
from,
|
|
to,
|
|
depthFrom: depthById.get(dependencyId) ?? 0,
|
|
depthTo: depthById.get(table.id) ?? 0,
|
|
sourceSlotIndex: 0,
|
|
sourceSlotCount: 1,
|
|
targetSlotIndex: 0,
|
|
targetSlotCount: 1,
|
|
laneOffset: 0,
|
|
};
|
|
edges.push(edge);
|
|
const outgoing = outgoingByNode.get(dependencyId) ?? [];
|
|
outgoing.push(edge);
|
|
outgoingByNode.set(dependencyId, outgoing);
|
|
const incoming = incomingByNode.get(table.id) ?? [];
|
|
incoming.push(edge);
|
|
incomingByNode.set(table.id, incoming);
|
|
}
|
|
}
|
|
|
|
for (const [nodeId, nodeEdges] of outgoingByNode.entries()) {
|
|
nodeEdges.sort((a, b) => compareNumbers(a.to.x, b.to.x) || compareStrings(a.toId, b.toId));
|
|
for (let i = 0; i < nodeEdges.length; i++) {
|
|
nodeEdges[i].sourceSlotIndex = i;
|
|
nodeEdges[i].sourceSlotCount = nodeEdges.length;
|
|
}
|
|
}
|
|
for (const [nodeId, nodeEdges] of incomingByNode.entries()) {
|
|
nodeEdges.sort((a, b) => compareNumbers(a.from.x, b.from.x) || compareStrings(a.fromId, b.fromId));
|
|
for (let i = 0; i < nodeEdges.length; i++) {
|
|
nodeEdges[i].targetSlotIndex = i;
|
|
nodeEdges[i].targetSlotCount = nodeEdges.length;
|
|
}
|
|
}
|
|
|
|
const edgesByDepthSpan = new Map();
|
|
for (const edge of edges) {
|
|
const bucketKey = edge.depthFrom + "->" + edge.depthTo;
|
|
const bucket = edgesByDepthSpan.get(bucketKey) ?? [];
|
|
bucket.push(edge);
|
|
edgesByDepthSpan.set(bucketKey, bucket);
|
|
}
|
|
for (const bucket of edgesByDepthSpan.values()) {
|
|
bucket.sort((a, b) => {
|
|
const aCenter = (a.from.x + a.to.x) / 2;
|
|
const bCenter = (b.from.x + b.to.x) / 2;
|
|
return compareNumbers(aCenter, bCenter) || compareStrings(a.id, b.id);
|
|
});
|
|
for (let i = 0; i < bucket.length; i++) {
|
|
const centeredIndex = i - (bucket.length - 1) / 2;
|
|
bucket[i].laneOffset = Math.max(-4, Math.min(4, centeredIndex)) * 18;
|
|
}
|
|
}
|
|
|
|
return edges;
|
|
}
|
|
function getEdgeAnchorX(position, slotIndex, slotCount) {
|
|
if (slotCount <= 1) return position.x + NODE_WIDTH / 2;
|
|
return position.x + ((slotIndex + 1) / (slotCount + 1)) * NODE_WIDTH;
|
|
}
|
|
function renderGraphEdges(tables) {
|
|
graphEdges.innerHTML = "";
|
|
if (!state.graphLayout) return;
|
|
const positions = state.graphLayout.positions;
|
|
|
|
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
|
|
const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
|
|
marker.setAttribute("id", "arrow");
|
|
marker.setAttribute("viewBox", "0 0 10 10");
|
|
marker.setAttribute("refX", "9");
|
|
marker.setAttribute("refY", "5");
|
|
marker.setAttribute("markerWidth", "7");
|
|
marker.setAttribute("markerHeight", "7");
|
|
marker.setAttribute("orient", "auto");
|
|
const markerPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
markerPath.setAttribute("d", "M 0 0 L 10 5 L 0 10 z");
|
|
markerPath.setAttribute("fill", "var(--accent)");
|
|
marker.appendChild(markerPath);
|
|
defs.appendChild(marker);
|
|
graphEdges.appendChild(defs);
|
|
|
|
const edges = buildGraphEdges(tables, positions, state.graphLayout.depthById ?? new Map());
|
|
for (const edge of edges) {
|
|
const startX = getEdgeAnchorX(edge.from, edge.sourceSlotIndex, edge.sourceSlotCount);
|
|
const startY = edge.from.y + NODE_HEIGHT;
|
|
const endX = getEdgeAnchorX(edge.to, edge.targetSlotIndex, edge.targetSlotCount);
|
|
const endY = edge.to.y;
|
|
const laneY = Math.min(endY - 20, Math.max(startY + 20, (startY + endY) / 2 + edge.laneOffset));
|
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
path.setAttribute("d", "M " + startX + " " + startY + " C " + startX + " " + laneY + ", " + endX + " " + laneY + ", " + endX + " " + endY);
|
|
path.setAttribute("fill", "none");
|
|
path.setAttribute("stroke", "var(--accent)");
|
|
path.setAttribute("stroke-width", "1.7");
|
|
path.setAttribute("stroke-linecap", "round");
|
|
path.setAttribute("stroke-linejoin", "round");
|
|
path.setAttribute("marker-end", "url(#arrow)");
|
|
graphEdges.appendChild(path);
|
|
}
|
|
}
|
|
function syncNodePositions() {
|
|
if (!state.graphLayout) return;
|
|
const positions = state.graphLayout.positions;
|
|
for (const node of graphNodes.querySelectorAll(".node")) {
|
|
const tableId = node.getAttribute("data-table-id");
|
|
if (!tableId) continue;
|
|
const position = positions.get(tableId);
|
|
if (!position) continue;
|
|
node.style.left = position.x + "px";
|
|
node.style.top = position.y + "px";
|
|
}
|
|
}
|
|
function relayoutGraph() {
|
|
if (!state.schema || !Array.isArray(state.schema.tables)) return;
|
|
pruneNodePositions(state.schema.tables);
|
|
state.graphLayout = layoutGraph(state.schema.tables, state.schema.layout);
|
|
syncSceneDimensions();
|
|
renderGraphEdges(state.schema.tables);
|
|
syncNodePositions();
|
|
updateSceneTransform();
|
|
}
|
|
|
|
function updateSceneTransform() {
|
|
graphScene.style.transform = "translate(" + state.viewport.x + "px, " + state.viewport.y + "px) scale(" + state.viewport.scale + ")";
|
|
}
|
|
|
|
function setMode(mode) {
|
|
state.mode = mode;
|
|
modeTablesBtn.classList.toggle("active", mode === "table");
|
|
modeRawBtn.classList.toggle("active", mode === "raw");
|
|
modeTimefoldBtn.classList.toggle("active", mode === "timefold");
|
|
renderDetails();
|
|
}
|
|
|
|
function fitGraphToView() {
|
|
if (!state.graphLayout) return;
|
|
const shellRect = graphShell.getBoundingClientRect();
|
|
const padding = 36;
|
|
const availableWidth = Math.max(120, shellRect.width - padding * 2);
|
|
const availableHeight = Math.max(120, shellRect.height - padding * 2);
|
|
const scaleX = availableWidth / state.graphLayout.sceneWidth;
|
|
const scaleY = availableHeight / state.graphLayout.sceneHeight;
|
|
const scale = Math.max(0.3, Math.min(2.2, Math.min(scaleX, scaleY)));
|
|
state.viewport.scale = scale;
|
|
state.viewport.x = (shellRect.width - state.graphLayout.sceneWidth * scale) / 2;
|
|
state.viewport.y = (shellRect.height - state.graphLayout.sceneHeight * scale) / 2;
|
|
updateSceneTransform();
|
|
}
|
|
|
|
function renderCategoryBoxes() {
|
|
const existing = graphNodes.querySelectorAll(".category-box");
|
|
for (const el of existing) el.remove();
|
|
if (!state.schema || !Array.isArray(state.schema.categories) || !state.graphLayout) return;
|
|
const positions = state.graphLayout.positions;
|
|
for (const category of state.schema.categories) {
|
|
const tableIds = category.tableIds || [];
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
let found = 0;
|
|
for (const tid of tableIds) {
|
|
const pos = positions.get(tid);
|
|
if (!pos) continue;
|
|
const node = graphNodes.querySelector('[data-table-id="' + tid.replaceAll('"', '\\\\"') + '"]');
|
|
if (node && node.style.display === "none") continue;
|
|
found++;
|
|
minX = Math.min(minX, pos.x);
|
|
minY = Math.min(minY, pos.y);
|
|
maxX = Math.max(maxX, pos.x + NODE_WIDTH);
|
|
maxY = Math.max(maxY, pos.y + NODE_HEIGHT);
|
|
}
|
|
if (found === 0) continue;
|
|
const pad = 24;
|
|
const box = document.createElement("div");
|
|
box.className = "category-box";
|
|
box.style.position = "absolute";
|
|
box.style.left = (minX - pad) + "px";
|
|
box.style.top = (minY - pad) + "px";
|
|
box.style.width = (maxX - minX + pad * 2) + "px";
|
|
box.style.height = (maxY - minY + pad * 2) + "px";
|
|
box.style.background = category.color || "rgba(128,128,128,0.08)";
|
|
box.style.borderRadius = "14px";
|
|
box.style.pointerEvents = "none";
|
|
box.style.zIndex = "0";
|
|
box.style.overflow = "hidden";
|
|
const label = document.createElement("div");
|
|
label.textContent = category.label || "";
|
|
label.style.position = "absolute";
|
|
label.style.top = "50%";
|
|
label.style.left = "50%";
|
|
label.style.transform = "translate(-50%, -50%)";
|
|
label.style.fontSize = "36px";
|
|
label.style.fontWeight = "800";
|
|
label.style.opacity = "0.18";
|
|
label.style.whiteSpace = "normal";
|
|
label.style.wordWrap = "break-word";
|
|
label.style.maxWidth = "90%";
|
|
label.style.letterSpacing = "1px";
|
|
label.style.userSelect = "none";
|
|
label.style.textAlign = "center";
|
|
box.appendChild(label);
|
|
graphNodes.insertBefore(box, graphNodes.firstChild);
|
|
}
|
|
}
|
|
|
|
function renderGraph() {
|
|
graphNodes.innerHTML = "";
|
|
graphEdges.innerHTML = "";
|
|
if (!state.schema || !Array.isArray(state.schema.tables)) return;
|
|
|
|
const tables = state.schema.tables;
|
|
for (const table of tables) {
|
|
const opNormalized = String(table.operator || "").toLowerCase();
|
|
if (!state.showIntermediates && INTERMEDIATE_OPERATORS.has(opNormalized)) {
|
|
continue;
|
|
}
|
|
const operatorClass = (() => {
|
|
const normalized = String(table.operator || "unknown").toLowerCase();
|
|
if (normalized === "stored" || normalized === "map" || normalized === "flatmap" || normalized === "groupby" || normalized === "filter" || normalized === "limit" || normalized === "concat" || normalized === "sort" || normalized === "lfold" || normalized === "leftjoin" || normalized === "compact" || normalized === "reduce" || normalized === "timefold") {
|
|
return normalized;
|
|
}
|
|
return "derived";
|
|
})();
|
|
const node = document.createElement("div");
|
|
node.className = "node" + (state.selectedTableId === table.id ? " active" : "");
|
|
node.setAttribute("data-table-id", String(table.id));
|
|
|
|
const type = document.createElement("div");
|
|
type.className = "node-type " + operatorClass;
|
|
type.textContent = String(table.operator || "unknown");
|
|
|
|
const name = document.createElement("div");
|
|
name.className = "node-name mono " + operatorClass;
|
|
name.textContent = table.name;
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "node-meta";
|
|
const changedRowsDiagnostics = changedRowsForTable(table);
|
|
meta.textContent = (table.initialized ? "initialized" : "not initialized")
|
|
+ " | deps: " + (Array.isArray(table.dependencies) ? table.dependencies.length : 0);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "node-actions";
|
|
const left = document.createElement("div");
|
|
left.className = "row";
|
|
if (!table.initialized) {
|
|
const initBtn = document.createElement("button");
|
|
initBtn.className = "btn bad";
|
|
initBtn.textContent = "🚀 init";
|
|
initBtn.onclick = (event) => {
|
|
event.stopPropagation();
|
|
runUiAction("init table", async () => {
|
|
await tableAction(table.id, "init");
|
|
});
|
|
};
|
|
left.appendChild(initBtn);
|
|
node.style.borderColor = "red";
|
|
}
|
|
const focusBtn = document.createElement("button");
|
|
focusBtn.className = "btn icon";
|
|
focusBtn.title = "Select table";
|
|
focusBtn.textContent = "🎯";
|
|
focusBtn.onclick = (event) => {
|
|
event.stopPropagation();
|
|
runUiAction("load table details", async () => {
|
|
setMode("table");
|
|
await selectTable(table.id);
|
|
});
|
|
};
|
|
actions.appendChild(left);
|
|
actions.appendChild(focusBtn);
|
|
|
|
if (changedRowsDiagnostics != null) {
|
|
const chipWrap = document.createElement("div");
|
|
chipWrap.className = "node-debug-chip-wrap";
|
|
const chip = document.createElement("div");
|
|
const hasMissingCoverage = changedRowsDiagnostics.expectedStatementCount != null
|
|
&& changedRowsDiagnostics.capturedStatementCount != null
|
|
&& changedRowsDiagnostics.capturedStatementCount < changedRowsDiagnostics.expectedStatementCount;
|
|
const changedRows = changedRowsDiagnostics.changedRows;
|
|
const isNonZeroDelta = changedRows != null && changedRows > 0;
|
|
chip.className = "node-debug-chip"
|
|
+ (hasMissingCoverage ? " warn" : "")
|
|
+ (isNonZeroDelta ? " nonzero" : "");
|
|
const changedRowsText = changedRows == null ? "?" : String(changedRows);
|
|
chip.textContent = "TMP Δ " + changedRowsText;
|
|
chip.title = hasMissingCoverage
|
|
? "Temporary debug delta from auto_explain diagnostics. Orange means capture is partial for this table, so this value may be incomplete."
|
|
: "Temporary debug delta from auto_explain diagnostics. Red means non-zero changed rows were observed for this table.";
|
|
chipWrap.appendChild(chip);
|
|
node.appendChild(chipWrap);
|
|
}
|
|
|
|
node.appendChild(type);
|
|
node.appendChild(name);
|
|
node.appendChild(meta);
|
|
node.appendChild(actions);
|
|
node.addEventListener("mousedown", (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof HTMLElement)) return;
|
|
if (target.closest("button")) return;
|
|
const position = state.graphLayout?.positions.get(table.id);
|
|
if (!position) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
state.dragging.active = true;
|
|
state.dragging.kind = "node";
|
|
state.dragging.nodeId = String(table.id);
|
|
state.dragging.startX = event.clientX;
|
|
state.dragging.startY = event.clientY;
|
|
state.dragging.nodeStartX = position.x;
|
|
state.dragging.nodeStartY = position.y;
|
|
state.dragging.moved = false;
|
|
node.classList.add("dragging");
|
|
graphShell.classList.add("dragging");
|
|
});
|
|
node.onclick = () => {
|
|
if (state.dragging.suppressClickTableId === table.id) {
|
|
state.dragging.suppressClickTableId = null;
|
|
return;
|
|
}
|
|
runUiAction("load table details", async () => {
|
|
setMode("table");
|
|
await selectTable(table.id);
|
|
});
|
|
};
|
|
graphNodes.appendChild(node);
|
|
}
|
|
|
|
relayoutGraph();
|
|
renderCategoryBoxes();
|
|
}
|
|
|
|
function getRawInputDefault() {
|
|
return "{\\n \\"accountId\\": \\"acct-demo\\",\\n \\"asset\\": \\"USD\\",\\n \\"amount\\": \\"1500.00\\",\\n \\"side\\": \\"credit\\",\\n \\"txHash\\": \\"0xdemo\\",\\n \\"blockNumber\\": 1,\\n \\"timestamp\\": \\"2026-01-01T00:00:00Z\\",\\n \\"counterparty\\": \\"acct-peer\\",\\n \\"memo\\": null\\n}";
|
|
}
|
|
|
|
async function loadSchema() {
|
|
setStatus("loading schema...");
|
|
const schema = await fetchJson("/api/schema");
|
|
state.schema = schema;
|
|
if (!state.selectedTableId && schema.tables.length > 0) {
|
|
state.selectedTableId = schema.tables[0].id;
|
|
}
|
|
renderGraph();
|
|
if (state.mode === "table" && state.selectedTableId) {
|
|
await selectTable(state.selectedTableId);
|
|
} else if (state.mode === "raw") {
|
|
await loadRawNode(state.rawPath.length === 0 ? [] : state.rawPath);
|
|
} else if (state.mode === "timefold") {
|
|
await loadTimefoldDebug();
|
|
}
|
|
setStatus("ready");
|
|
fitGraphToView();
|
|
}
|
|
|
|
async function selectTable(tableId) {
|
|
state.selectedTableId = tableId;
|
|
setStatus("loading table...");
|
|
state.selectedTableDetails = await fetchJson("/api/table/" + encodeURIComponent(tableId) + "/details");
|
|
setStatus("ready");
|
|
renderGraph();
|
|
renderDetails();
|
|
}
|
|
|
|
async function tableAction(tableId, action, payload) {
|
|
setStatus(action + "...");
|
|
const response = await fetchJson("/api/table/" + encodeURIComponent(tableId) + "/" + action, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload || {}),
|
|
});
|
|
if ((action === "set-row" || action === "delete-row") && response && typeof response === "object") {
|
|
const metrics = response.metrics;
|
|
if (metrics && typeof metrics === "object") {
|
|
state.lastMutationMetrics[tableId + ":" + action] = metrics;
|
|
}
|
|
updateRowChangeDiagnosticsFromMetrics(metrics);
|
|
}
|
|
await loadSchema();
|
|
if (state.selectedTableId) {
|
|
await selectTable(state.selectedTableId);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
async function initAllTables() {
|
|
setStatus("initializing all tables...");
|
|
await fetchJson("/api/tables/init-all", {
|
|
method: "POST",
|
|
});
|
|
await loadSchema();
|
|
if (state.selectedTableId) {
|
|
await selectTable(state.selectedTableId);
|
|
}
|
|
}
|
|
|
|
async function loadRawNode(path) {
|
|
state.mode = "raw";
|
|
setStatus("loading raw node...");
|
|
state.rawNode = await fetchJson("/api/raw/node?path=" + encodeURIComponent(JSON.stringify(path)));
|
|
state.rawPath = state.rawNode.path;
|
|
setStatus("ready");
|
|
renderDetails();
|
|
}
|
|
|
|
async function loadTimefoldDebug() {
|
|
state.mode = "timefold";
|
|
setStatus("loading timefold debug...");
|
|
state.timefoldDebug = await fetchJson("/api/timefold/debug");
|
|
setStatus("ready");
|
|
renderDetails();
|
|
}
|
|
|
|
async function upsertRaw(path, value) {
|
|
setStatus("saving raw value...");
|
|
await fetchJson("/api/raw/upsert", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ pathSegments: path, value }),
|
|
});
|
|
await loadRawNode(path);
|
|
}
|
|
|
|
async function deleteRaw(path) {
|
|
setStatus("deleting raw value...");
|
|
await fetchJson("/api/raw/delete", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ pathSegments: path }),
|
|
});
|
|
await loadRawNode(path.length === 0 ? [] : path.slice(0, -1));
|
|
}
|
|
|
|
function renderTableDetails() {
|
|
const details = state.selectedTableDetails;
|
|
if (!details) {
|
|
detailsPane.innerHTML = "<div class='detail-section'>No table selected.</div>";
|
|
return;
|
|
}
|
|
const table = details.table;
|
|
detailsPane.innerHTML = "";
|
|
|
|
const head = document.createElement("div");
|
|
head.className = "detail-head";
|
|
const title = document.createElement("div");
|
|
title.className = "detail-title";
|
|
title.textContent = "Table details";
|
|
const actions = document.createElement("div");
|
|
actions.className = "row";
|
|
const refreshBtnLocal = document.createElement("button");
|
|
refreshBtnLocal.className = "btn icon";
|
|
refreshBtnLocal.title = "Refresh details";
|
|
refreshBtnLocal.textContent = "🔄";
|
|
refreshBtnLocal.onclick = () => runUiAction("refresh details", async () => {
|
|
await selectTable(table.id);
|
|
});
|
|
const initBtn = document.createElement("button");
|
|
initBtn.className = "btn good";
|
|
initBtn.textContent = "🚀 init";
|
|
initBtn.onclick = () => runUiAction("init table", async () => {
|
|
await tableAction(table.id, "init");
|
|
});
|
|
const deleteBtn = document.createElement("button");
|
|
deleteBtn.className = "btn bad icon";
|
|
deleteBtn.title = "Delete table";
|
|
deleteBtn.textContent = "🗑️";
|
|
deleteBtn.onclick = () => runUiAction("delete table", async () => {
|
|
await tableAction(table.id, "delete");
|
|
});
|
|
actions.appendChild(refreshBtnLocal);
|
|
actions.appendChild(initBtn);
|
|
actions.appendChild(deleteBtn);
|
|
head.appendChild(title);
|
|
head.appendChild(actions);
|
|
detailsPane.appendChild(head);
|
|
|
|
const info = document.createElement("div");
|
|
info.className = "detail-section";
|
|
const kv = document.createElement("div");
|
|
kv.className = "kv";
|
|
const appendInfoRow = (label, value, isMonospace = false) => {
|
|
const keyCell = document.createElement("div");
|
|
keyCell.className = "kv-key";
|
|
keyCell.textContent = label;
|
|
const valueCell = document.createElement("div");
|
|
if (isMonospace) {
|
|
valueCell.className = "mono";
|
|
}
|
|
valueCell.textContent = value;
|
|
kv.appendChild(keyCell);
|
|
kv.appendChild(valueCell);
|
|
};
|
|
appendInfoRow("name", table.name, true);
|
|
appendInfoRow("tableId", table.tableId, true);
|
|
appendInfoRow("operator", table.operator, true);
|
|
appendInfoRow("initialized", String(table.initialized));
|
|
appendInfoRow("dependencies", table.dependencies.length === 0 ? "(none)" : table.dependencies.join(", "), true);
|
|
appendInfoRow("rows(all groups)", String(details.totalRows), true);
|
|
info.appendChild(kv);
|
|
detailsPane.appendChild(info);
|
|
|
|
const debugArgs = document.createElement("div");
|
|
debugArgs.className = "detail-section";
|
|
debugArgs.innerHTML = "<div class='muted mono' style='margin-bottom:6px;'>debugArgs</div>";
|
|
const debugArgsGrid = document.createElement("div");
|
|
debugArgsGrid.className = "kv";
|
|
const debugEntries = Object.entries(table.debugArgs ?? {}).sort((a, b) => compareStrings(a[0], b[0]));
|
|
if (debugEntries.length === 0) {
|
|
const emptyKey = document.createElement("div");
|
|
emptyKey.className = "kv-key mono";
|
|
emptyKey.textContent = "(none)";
|
|
const emptyValue = document.createElement("div");
|
|
emptyValue.className = "mono";
|
|
emptyValue.textContent = "-";
|
|
debugArgsGrid.appendChild(emptyKey);
|
|
debugArgsGrid.appendChild(emptyValue);
|
|
}
|
|
for (const [key, value] of debugEntries) {
|
|
const keyCell = document.createElement("div");
|
|
keyCell.className = "kv-key mono";
|
|
keyCell.textContent = key;
|
|
|
|
const valueCell = document.createElement("div");
|
|
if (value === null || typeof value === "number" || typeof value === "boolean" || typeof value === "undefined") {
|
|
valueCell.className = "mono";
|
|
valueCell.textContent = String(value);
|
|
} else if (typeof value === "string") {
|
|
if (value.includes("\\n") || value.length > 120) {
|
|
const valuePre = document.createElement("pre");
|
|
valuePre.textContent = value;
|
|
valueCell.appendChild(valuePre);
|
|
} else {
|
|
valueCell.className = "mono";
|
|
valueCell.textContent = value;
|
|
}
|
|
} else {
|
|
const valuePre = document.createElement("pre");
|
|
valuePre.textContent = prettyJson(value);
|
|
valueCell.appendChild(valuePre);
|
|
}
|
|
|
|
debugArgsGrid.appendChild(keyCell);
|
|
debugArgsGrid.appendChild(valueCell);
|
|
}
|
|
debugArgs.appendChild(debugArgsGrid);
|
|
detailsPane.appendChild(debugArgs);
|
|
|
|
if (table.supportsSetRow) {
|
|
const mutate = document.createElement("div");
|
|
mutate.className = "detail-section";
|
|
mutate.innerHTML = "<div class='muted mono' style='margin-bottom:6px;'>mutations</div>";
|
|
const setMetrics = state.lastMutationMetrics[table.id + ":set-row"];
|
|
const deleteMetrics = state.lastMutationMetrics[table.id + ":delete-row"];
|
|
const setRowId = document.createElement("input");
|
|
setRowId.placeholder = "rowIdentifier";
|
|
const setRowData = document.createElement("textarea");
|
|
setRowData.value = getRawInputDefault();
|
|
const setRowActions = document.createElement("div");
|
|
setRowActions.className = "row";
|
|
const setBtn = document.createElement("button");
|
|
setBtn.className = "btn good";
|
|
setBtn.textContent = "💾 set row";
|
|
setBtn.onclick = () => runUiAction("set row", async () => {
|
|
await runButtonAction(setBtn, "⏳ setting...", async () => {
|
|
const rowIdentifier = setRowId.value.trim();
|
|
if (!rowIdentifier) {
|
|
throw new Error("rowIdentifier is required");
|
|
}
|
|
const rowData = JSON.parse(setRowData.value);
|
|
await tableAction(table.id, "set-row", { rowIdentifier, rowData });
|
|
});
|
|
});
|
|
const deleteRowId = document.createElement("input");
|
|
deleteRowId.placeholder = "rowIdentifier to delete";
|
|
const deleteRowBtn = document.createElement("button");
|
|
deleteRowBtn.className = "btn bad";
|
|
deleteRowBtn.textContent = "🗑️ delete row";
|
|
deleteRowBtn.onclick = () => runUiAction("delete row", async () => {
|
|
await runButtonAction(deleteRowBtn, "⏳ deleting...", async () => {
|
|
const rowIdentifier = deleteRowId.value.trim();
|
|
if (!rowIdentifier) {
|
|
throw new Error("rowIdentifier is required");
|
|
}
|
|
await tableAction(table.id, "delete-row", { rowIdentifier });
|
|
});
|
|
});
|
|
setRowActions.appendChild(setBtn);
|
|
setRowActions.appendChild(deleteRowBtn);
|
|
mutate.appendChild(setRowId);
|
|
mutate.appendChild(setRowData);
|
|
mutate.appendChild(deleteRowId);
|
|
mutate.appendChild(setRowActions);
|
|
if (setMetrics && typeof setMetrics === "object") {
|
|
const setMetricsRow = document.createElement("div");
|
|
setMetricsRow.className = "row";
|
|
const setDurationBtn = document.createElement("button");
|
|
setDurationBtn.className = "btn";
|
|
setDurationBtn.style.fontSize = "11px";
|
|
const setDuration = readMutationDurationMs(setMetrics);
|
|
setDurationBtn.textContent = "⏱ set " + formatDuration(setDuration ?? Number.NaN);
|
|
setDurationBtn.onclick = () => showMetricsDialog("set-row", table.tableId, setMetrics);
|
|
const setMetricsSummary = document.createElement("div");
|
|
setMetricsSummary.className = "mono muted";
|
|
setMetricsSummary.style.fontSize = "11px";
|
|
setMetricsSummary.textContent = formatMutationMetrics(setMetrics);
|
|
setMetricsRow.appendChild(setDurationBtn);
|
|
setMetricsRow.appendChild(setMetricsSummary);
|
|
mutate.appendChild(setMetricsRow);
|
|
}
|
|
if (deleteMetrics && typeof deleteMetrics === "object") {
|
|
const deleteMetricsRow = document.createElement("div");
|
|
deleteMetricsRow.className = "row";
|
|
const deleteDurationBtn = document.createElement("button");
|
|
deleteDurationBtn.className = "btn";
|
|
deleteDurationBtn.style.fontSize = "11px";
|
|
const deleteDuration = readMutationDurationMs(deleteMetrics);
|
|
deleteDurationBtn.textContent = "⏱ delete " + formatDuration(deleteDuration ?? Number.NaN);
|
|
deleteDurationBtn.onclick = () => showMetricsDialog("delete-row", table.tableId, deleteMetrics);
|
|
const deleteMetricsSummary = document.createElement("div");
|
|
deleteMetricsSummary.className = "mono muted";
|
|
deleteMetricsSummary.style.fontSize = "11px";
|
|
deleteMetricsSummary.textContent = formatMutationMetrics(deleteMetrics);
|
|
deleteMetricsRow.appendChild(deleteDurationBtn);
|
|
deleteMetricsRow.appendChild(deleteMetricsSummary);
|
|
mutate.appendChild(deleteMetricsRow);
|
|
}
|
|
detailsPane.appendChild(mutate);
|
|
}
|
|
|
|
const rowsSection = document.createElement("div");
|
|
rowsSection.className = "detail-section";
|
|
rowsSection.innerHTML = "<div class='muted mono' style='margin-bottom:6px;'>rows grouped by groupKey</div>";
|
|
for (const group of details.groups) {
|
|
const detailsElement = document.createElement("details");
|
|
detailsElement.open = details.groups.length <= 8;
|
|
const summary = document.createElement("summary");
|
|
summary.textContent = "group=" + prettyJson(group.groupKey) + " (" + group.rows.length + " rows)";
|
|
detailsElement.appendChild(summary);
|
|
const tableElement = document.createElement("table");
|
|
tableElement.innerHTML = "<thead><tr><th>rowIdentifier</th><th>rowSortKey</th><th>rowData</th></tr></thead>";
|
|
const tbody = document.createElement("tbody");
|
|
for (const row of group.rows) {
|
|
const tr = document.createElement("tr");
|
|
const idCell = document.createElement("td");
|
|
idCell.className = "mono";
|
|
idCell.textContent = prettyJson(row.rowIdentifier);
|
|
const sortCell = document.createElement("td");
|
|
sortCell.className = "mono";
|
|
sortCell.textContent = prettyJson(row.rowSortKey);
|
|
const dataCell = document.createElement("td");
|
|
const pre = document.createElement("pre");
|
|
pre.textContent = prettyJson(row.rowData);
|
|
dataCell.appendChild(pre);
|
|
tr.appendChild(idCell);
|
|
tr.appendChild(sortCell);
|
|
tr.appendChild(dataCell);
|
|
tbody.appendChild(tr);
|
|
}
|
|
tableElement.appendChild(tbody);
|
|
detailsElement.appendChild(tableElement);
|
|
rowsSection.appendChild(detailsElement);
|
|
}
|
|
detailsPane.appendChild(rowsSection);
|
|
}
|
|
|
|
function renderRawDetails() {
|
|
detailsPane.innerHTML = "";
|
|
const head = document.createElement("div");
|
|
head.className = "detail-head";
|
|
const title = document.createElement("div");
|
|
title.className = "detail-title";
|
|
title.textContent = "Raw BulldozerStorageEngine";
|
|
const controls = document.createElement("div");
|
|
controls.className = "row";
|
|
const loadRootBtn = document.createElement("button");
|
|
loadRootBtn.className = "btn";
|
|
loadRootBtn.textContent = "🧷 root";
|
|
loadRootBtn.onclick = () => runUiAction("load raw root", async () => {
|
|
await loadRawNode([]);
|
|
});
|
|
const refreshRawBtn = document.createElement("button");
|
|
refreshRawBtn.className = "btn icon";
|
|
refreshRawBtn.title = "Refresh";
|
|
refreshRawBtn.textContent = "🔄";
|
|
refreshRawBtn.onclick = () => runUiAction("refresh raw", async () => {
|
|
await loadRawNode(state.rawPath.length === 0 ? [] : state.rawPath);
|
|
});
|
|
controls.appendChild(loadRootBtn);
|
|
controls.appendChild(refreshRawBtn);
|
|
head.appendChild(title);
|
|
head.appendChild(controls);
|
|
detailsPane.appendChild(head);
|
|
|
|
if (!state.rawNode) {
|
|
detailsPane.appendChild(document.createTextNode("No raw node selected."));
|
|
return;
|
|
}
|
|
|
|
const location = document.createElement("div");
|
|
location.className = "detail-section mono";
|
|
location.textContent = "path: " + JSON.stringify(state.rawNode.path);
|
|
detailsPane.appendChild(location);
|
|
|
|
const valueSection = document.createElement("div");
|
|
valueSection.className = "detail-section";
|
|
valueSection.innerHTML = "<div class='muted mono' style='margin-bottom:6px;'>value</div>";
|
|
const valueEditor = document.createElement("textarea");
|
|
valueEditor.value = prettyJson(state.rawNode.value);
|
|
const valueActions = document.createElement("div");
|
|
valueActions.className = "row";
|
|
const saveBtn = document.createElement("button");
|
|
saveBtn.className = "btn good";
|
|
saveBtn.textContent = "💾 upsert";
|
|
saveBtn.onclick = () => runUiAction("upsert raw value", async () => {
|
|
await upsertRaw(state.rawNode.path, JSON.parse(valueEditor.value));
|
|
});
|
|
const deleteBtn = document.createElement("button");
|
|
deleteBtn.className = "btn bad";
|
|
deleteBtn.textContent = "🗑️ delete";
|
|
deleteBtn.onclick = () => runUiAction("delete raw value", async () => {
|
|
await deleteRaw(state.rawNode.path);
|
|
});
|
|
const upBtn = document.createElement("button");
|
|
upBtn.className = "btn";
|
|
upBtn.textContent = "⬆️ up";
|
|
upBtn.onclick = () => runUiAction("go up", async () => {
|
|
const nextPath = state.rawNode.path.length === 0 ? [] : state.rawNode.path.slice(0, -1);
|
|
await loadRawNode(nextPath);
|
|
});
|
|
valueActions.appendChild(saveBtn);
|
|
valueActions.appendChild(deleteBtn);
|
|
valueActions.appendChild(upBtn);
|
|
valueSection.appendChild(valueEditor);
|
|
valueSection.appendChild(valueActions);
|
|
detailsPane.appendChild(valueSection);
|
|
|
|
const children = document.createElement("div");
|
|
children.className = "detail-section";
|
|
children.innerHTML = "<div class='muted mono' style='margin-bottom:6px;'>children</div>";
|
|
const createRow = document.createElement("div");
|
|
createRow.className = "row";
|
|
const childInput = document.createElement("input");
|
|
childInput.placeholder = "child segment";
|
|
const openBtn = document.createElement("button");
|
|
openBtn.className = "btn";
|
|
openBtn.textContent = "➡️ open";
|
|
openBtn.onclick = () => runUiAction("open child", async () => {
|
|
const segment = childInput.value.trim();
|
|
if (!segment) {
|
|
throw new Error("child segment is required");
|
|
}
|
|
await loadRawNode(state.rawNode.path.concat([segment]));
|
|
});
|
|
createRow.appendChild(childInput);
|
|
createRow.appendChild(openBtn);
|
|
children.appendChild(createRow);
|
|
const childrenList = document.createElement("div");
|
|
childrenList.className = "raw-children";
|
|
for (const child of state.rawNode.children) {
|
|
const childBtn = document.createElement("button");
|
|
childBtn.className = "btn mono";
|
|
childBtn.textContent = child.segment + (child.hasChildren ? "/" : "");
|
|
childBtn.onclick = () => runUiAction("open child", async () => {
|
|
await loadRawNode(state.rawNode.path.concat([child.segment]));
|
|
});
|
|
childrenList.appendChild(childBtn);
|
|
}
|
|
children.appendChild(childrenList);
|
|
detailsPane.appendChild(children);
|
|
}
|
|
|
|
function renderTimefoldDetails() {
|
|
detailsPane.innerHTML = "";
|
|
|
|
const head = document.createElement("div");
|
|
head.className = "detail-head";
|
|
const title = document.createElement("div");
|
|
title.className = "detail-title";
|
|
title.textContent = "Timefold queue debug";
|
|
const actions = document.createElement("div");
|
|
actions.className = "row";
|
|
const refreshTimefoldBtn = document.createElement("button");
|
|
refreshTimefoldBtn.className = "btn icon";
|
|
refreshTimefoldBtn.title = "Refresh timefold debug";
|
|
refreshTimefoldBtn.textContent = "🔄";
|
|
refreshTimefoldBtn.onclick = () => runUiAction("refresh timefold debug", async () => {
|
|
await loadTimefoldDebug();
|
|
});
|
|
actions.appendChild(refreshTimefoldBtn);
|
|
head.appendChild(title);
|
|
head.appendChild(actions);
|
|
detailsPane.appendChild(head);
|
|
|
|
if (state.timefoldDebug == null || typeof state.timefoldDebug !== "object") {
|
|
detailsPane.innerHTML += "<div class='detail-section'>No timefold debug data loaded.</div>";
|
|
return;
|
|
}
|
|
|
|
const debug = state.timefoldDebug;
|
|
const queueRows = Array.isArray(debug.queue) ? debug.queue : [];
|
|
const queueTableExists = debug.queueTableExists === true;
|
|
const metadataTableExists = debug.metadataTableExists === true;
|
|
const pgCronInstalled = debug.pgCronInstalled === true;
|
|
|
|
const info = document.createElement("div");
|
|
info.className = "detail-section";
|
|
const infoTitle = document.createElement("div");
|
|
infoTitle.className = "muted mono";
|
|
infoTitle.style.marginBottom = "6px";
|
|
infoTitle.textContent = "metadata";
|
|
info.appendChild(infoTitle);
|
|
const infoGrid = document.createElement("div");
|
|
infoGrid.className = "kv";
|
|
const infoRows = [
|
|
["queue table exists", String(queueTableExists)],
|
|
["metadata table exists", String(metadataTableExists)],
|
|
["pg_cron installed", String(pgCronInstalled)],
|
|
["lastProcessedAt", String(debug.lastProcessedAt ?? "null")],
|
|
["queue rows", String(queueRows.length)],
|
|
];
|
|
for (const [label, value] of infoRows) {
|
|
const keyCell = document.createElement("div");
|
|
keyCell.className = "kv-key mono";
|
|
keyCell.textContent = label;
|
|
const valueCell = document.createElement("div");
|
|
valueCell.className = "mono";
|
|
valueCell.textContent = value;
|
|
infoGrid.appendChild(keyCell);
|
|
infoGrid.appendChild(valueCell);
|
|
}
|
|
info.appendChild(infoGrid);
|
|
detailsPane.appendChild(info);
|
|
|
|
const queueSection = document.createElement("div");
|
|
queueSection.className = "detail-section";
|
|
queueSection.innerHTML = "<div class='muted mono' style='margin-bottom:6px;'>queue rows (up to 500)</div>";
|
|
if (queueRows.length === 0) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "mono muted";
|
|
empty.textContent = "(empty)";
|
|
queueSection.appendChild(empty);
|
|
}
|
|
for (const queueRow of queueRows) {
|
|
const rowIdentifier = queueRow.rowIdentifier ?? queueRow.rowidentifier ?? "(unknown)";
|
|
const scheduledAt = queueRow.scheduledAt ?? queueRow.scheduledat ?? "(unknown)";
|
|
const detailsElement = document.createElement("details");
|
|
detailsElement.open = queueRows.length <= 10;
|
|
const summary = document.createElement("summary");
|
|
summary.textContent = String(scheduledAt) + " | rowIdentifier=" + String(rowIdentifier);
|
|
detailsElement.appendChild(summary);
|
|
const rowGrid = document.createElement("div");
|
|
rowGrid.className = "kv";
|
|
const fields = [
|
|
["id", queueRow.id ?? null],
|
|
["tableStoragePath", queueRow.tableStoragePath ?? queueRow.tablestoragepath ?? null],
|
|
["groupKey", queueRow.groupKey ?? queueRow.groupkey ?? null],
|
|
["rowIdentifier", rowIdentifier],
|
|
["scheduledAt", scheduledAt],
|
|
["stateAfter", queueRow.stateAfter ?? queueRow.stateafter ?? null],
|
|
["rowData", queueRow.rowData ?? queueRow.rowdata ?? null],
|
|
["createdAt", queueRow.createdAt ?? queueRow.createdat ?? null],
|
|
["updatedAt", queueRow.updatedAt ?? queueRow.updatedat ?? null],
|
|
["reducerSql", queueRow.reducerSql ?? queueRow.reducersql ?? null],
|
|
];
|
|
for (const [field, value] of fields) {
|
|
const keyCell = document.createElement("div");
|
|
keyCell.className = "kv-key mono";
|
|
keyCell.textContent = field;
|
|
const valueCell = document.createElement("div");
|
|
if (typeof value === "string" && !value.includes("\\n") && value.length <= 140) {
|
|
valueCell.className = "mono";
|
|
valueCell.textContent = value;
|
|
} else {
|
|
const pre = document.createElement("pre");
|
|
pre.textContent = prettyJson(value);
|
|
valueCell.appendChild(pre);
|
|
}
|
|
rowGrid.appendChild(keyCell);
|
|
rowGrid.appendChild(valueCell);
|
|
}
|
|
detailsElement.appendChild(rowGrid);
|
|
queueSection.appendChild(detailsElement);
|
|
}
|
|
detailsPane.appendChild(queueSection);
|
|
}
|
|
|
|
function renderDetails() {
|
|
if (state.mode === "raw") {
|
|
renderRawDetails();
|
|
} else if (state.mode === "timefold") {
|
|
renderTimefoldDetails();
|
|
} else {
|
|
renderTableDetails();
|
|
}
|
|
}
|
|
|
|
function setTheme(theme, options = { persist: true }) {
|
|
state.theme = theme;
|
|
document.body.setAttribute("data-theme", theme);
|
|
themeBtn.textContent = theme === "dark" ? "🌙" : "☀️";
|
|
if (options.persist) {
|
|
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
}
|
|
renderGraph();
|
|
renderDetails();
|
|
}
|
|
|
|
function loadInitialTheme() {
|
|
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
if (storedTheme === "dark" || storedTheme === "light") {
|
|
return storedTheme;
|
|
}
|
|
const systemTheme = resolveSystemTheme();
|
|
window.localStorage.setItem(THEME_STORAGE_KEY, systemTheme);
|
|
return systemTheme;
|
|
}
|
|
|
|
async function monitorServerVersion() {
|
|
setInterval(async () => {
|
|
try {
|
|
const result = await fetchJson("/api/version");
|
|
const serverVersion = typeof result.version === "string" ? result.version : null;
|
|
if (serverVersion == null) return;
|
|
if (state.serverVersion == null) {
|
|
state.serverVersion = serverVersion;
|
|
return;
|
|
}
|
|
if (serverVersion !== state.serverVersion) {
|
|
window.location.reload();
|
|
}
|
|
} catch (error) {
|
|
// Ignore polling failures while the dev server restarts.
|
|
}
|
|
}, VERSION_POLL_INTERVAL_MS);
|
|
}
|
|
|
|
function configurePanZoom() {
|
|
graphShell.addEventListener("wheel", (event) => {
|
|
event.preventDefault();
|
|
const rect = graphShell.getBoundingClientRect();
|
|
const pointerX = event.clientX - rect.left;
|
|
const pointerY = event.clientY - rect.top;
|
|
const oldScale = state.viewport.scale;
|
|
const zoomMultiplier = event.deltaY < 0 ? 1.08 : 0.92;
|
|
const newScale = Math.max(0.3, Math.min(2.4, oldScale * zoomMultiplier));
|
|
const worldX = (pointerX - state.viewport.x) / oldScale;
|
|
const worldY = (pointerY - state.viewport.y) / oldScale;
|
|
state.viewport.scale = newScale;
|
|
state.viewport.x = pointerX - worldX * newScale;
|
|
state.viewport.y = pointerY - worldY * newScale;
|
|
updateSceneTransform();
|
|
}, { passive: false });
|
|
|
|
graphShell.addEventListener("mousedown", (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof HTMLElement)) return;
|
|
if (target.closest(".node")) return;
|
|
state.dragging.active = true;
|
|
state.dragging.kind = "pan";
|
|
state.dragging.startX = event.clientX;
|
|
state.dragging.startY = event.clientY;
|
|
state.dragging.startOffsetX = state.viewport.x;
|
|
state.dragging.startOffsetY = state.viewport.y;
|
|
state.dragging.moved = false;
|
|
graphShell.classList.add("dragging");
|
|
});
|
|
|
|
window.addEventListener("mousemove", (event) => {
|
|
if (!state.dragging.active) return;
|
|
const deltaX = event.clientX - state.dragging.startX;
|
|
const deltaY = event.clientY - state.dragging.startY;
|
|
if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) {
|
|
state.dragging.moved = true;
|
|
}
|
|
if (state.dragging.kind === "pan") {
|
|
state.viewport.x = state.dragging.startOffsetX + deltaX;
|
|
state.viewport.y = state.dragging.startOffsetY + deltaY;
|
|
updateSceneTransform();
|
|
return;
|
|
}
|
|
if (state.dragging.kind === "node" && state.dragging.nodeId) {
|
|
const nextX = Math.max(SCENE_MARGIN / 2, state.dragging.nodeStartX + deltaX / state.viewport.scale);
|
|
const nextY = Math.max(SCENE_MARGIN / 2, state.dragging.nodeStartY + deltaY / state.viewport.scale);
|
|
state.manualNodePositions[state.dragging.nodeId] = { x: nextX, y: nextY };
|
|
persistNodePositions();
|
|
relayoutGraph();
|
|
}
|
|
});
|
|
|
|
window.addEventListener("mouseup", () => {
|
|
if (state.dragging.kind === "node" && state.dragging.nodeId && state.dragging.moved) {
|
|
state.dragging.suppressClickTableId = state.dragging.nodeId;
|
|
}
|
|
const draggingNodeId = state.dragging.nodeId;
|
|
if (draggingNodeId) {
|
|
const node = graphNodes.querySelector('[data-table-id="' + draggingNodeId.replaceAll('"', '\\"') + '"]');
|
|
if (node instanceof HTMLElement) {
|
|
node.classList.remove("dragging");
|
|
}
|
|
}
|
|
state.dragging.active = false;
|
|
state.dragging.kind = null;
|
|
state.dragging.nodeId = null;
|
|
state.dragging.moved = false;
|
|
graphShell.classList.remove("dragging");
|
|
});
|
|
}
|
|
|
|
modeTablesBtn.onclick = () => runUiAction("switch mode", async () => {
|
|
setMode("table");
|
|
if (state.selectedTableId) {
|
|
await selectTable(state.selectedTableId);
|
|
} else {
|
|
renderDetails();
|
|
}
|
|
});
|
|
modeRawBtn.onclick = () => runUiAction("switch mode", async () => {
|
|
setMode("raw");
|
|
await loadRawNode(state.rawPath.length === 0 ? [] : state.rawPath);
|
|
});
|
|
toggleIntermediatesBtn.onclick = () => {
|
|
state.showIntermediates = !state.showIntermediates;
|
|
toggleIntermediatesBtn.textContent = state.showIntermediates ? "👁 Intermediates" : "👁🗨 Intermediates";
|
|
renderGraph();
|
|
};
|
|
schemaSelect.onchange = () => runUiAction("switch schema", async () => {
|
|
setStatus("switching schema...");
|
|
state.selectedTableId = null;
|
|
state.selectedTableDetails = null;
|
|
await fetchJson("/api/switch-schema", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name: schemaSelect.value }),
|
|
});
|
|
await loadSchema();
|
|
renderDetails();
|
|
setStatus("ready");
|
|
});
|
|
modeTimefoldBtn.onclick = () => runUiAction("switch mode", async () => {
|
|
setMode("timefold");
|
|
await loadTimefoldDebug();
|
|
});
|
|
initAllBtn.onclick = () => runUiAction("init all tables", async () => {
|
|
await initAllTables();
|
|
});
|
|
refreshBtn.onclick = () => runUiAction("refresh", async () => {
|
|
await loadSchema();
|
|
});
|
|
fitBtn.onclick = () => {
|
|
fitGraphToView();
|
|
};
|
|
themeBtn.onclick = () => {
|
|
setTheme(state.theme === "dark" ? "light" : "dark");
|
|
};
|
|
errorCloseBtn.onclick = () => {
|
|
if (errorDialog.open) {
|
|
errorDialog.close();
|
|
}
|
|
};
|
|
metricsDialogCloseBtn.onclick = () => {
|
|
if (metricsDialog.open) {
|
|
metricsDialog.close();
|
|
}
|
|
};
|
|
|
|
configurePanZoom();
|
|
const initialTheme = loadInitialTheme();
|
|
setTheme(initialTheme, { persist: false });
|
|
monitorServerVersion();
|
|
runUiAction("initial load", async () => {
|
|
await loadSchemaList();
|
|
await loadSchema();
|
|
renderDetails();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
async function handleRequest(request: http.IncomingMessage, response: http.ServerResponse): Promise<void> {
|
|
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
|
throw new StackAssertionError("Bulldozer Studio only accepts loopback requests.", {
|
|
remoteAddress: request.socket.remoteAddress,
|
|
});
|
|
}
|
|
|
|
const requestUrl = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
const pathname = requestUrl.pathname;
|
|
const method = request.method ?? "GET";
|
|
if (method === "POST") {
|
|
requireAuthorizedMutationRequest(request, requestUrl);
|
|
}
|
|
|
|
if (method === "GET" && pathname === "/") {
|
|
sendHtml(response, getStudioPageHtml());
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && pathname === "/api/version") {
|
|
sendJson(response, 200, { version: STUDIO_INSTANCE_ID });
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && pathname === "/api/schemas") {
|
|
sendJson(response, 200, {
|
|
available: Object.keys(AVAILABLE_SCHEMAS),
|
|
current: currentSchemaName,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && pathname === "/api/switch-schema") {
|
|
const body = await readRequestBody(request);
|
|
const parsed = JSON.parse(body);
|
|
const name = parsed?.name;
|
|
const schemaFactory = typeof name === "string" ? Reflect.get(AVAILABLE_SCHEMAS, name) : null;
|
|
if (typeof name !== "string" || typeof schemaFactory !== "function") {
|
|
sendJson(response, 400, { error: `Unknown schema "${name}". Available: ${Object.keys(AVAILABLE_SCHEMAS).join(", ")}` });
|
|
return;
|
|
}
|
|
switchSchema(name);
|
|
sendJson(response, 200, { ok: true, current: currentSchemaName });
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && pathname === "/api/schema") {
|
|
const tables = await Promise.all(registry.tables.map((table) => getTableSnapshot(table)));
|
|
const layout = await computeStudioLayout(tables);
|
|
sendJson(response, 200, { tables, layout, currentSchema: currentSchemaName, categories: registry.categories });
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && pathname === "/api/timefold/debug") {
|
|
const snapshot = await getTimefoldDebugSnapshot();
|
|
sendJson(response, 200, snapshot);
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && pathname === "/api/tables/init-all") {
|
|
const initializedTableIds = await initAllTablesInTopologicalOrder();
|
|
sendJson(response, 200, { ok: true, initializedTableIds });
|
|
return;
|
|
}
|
|
|
|
if (pathname.startsWith("/api/table/")) {
|
|
const pathParts = pathname.split("/").filter(Boolean);
|
|
const tableId = decodeURIComponent(pathParts[2] ?? "");
|
|
const record = registry.tableById.get(tableId);
|
|
if (!record) {
|
|
sendJson(response, 404, { error: `Unknown table: ${tableId}` });
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && pathParts[3] === "details") {
|
|
const details = await getTableDetails(record);
|
|
sendJson(response, 200, details);
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && pathParts[3] === "init") {
|
|
const executionContext = createBulldozerExecutionContext();
|
|
await executeStatements(record.table.init(executionContext));
|
|
sendJson(response, 200, { ok: true });
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && pathParts[3] === "delete") {
|
|
const executionContext = createBulldozerExecutionContext();
|
|
await executeStatements(record.table.delete(executionContext));
|
|
sendJson(response, 200, { ok: true });
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && pathParts[3] === "set-row") {
|
|
if (!isStudioStoredTable(record.table)) {
|
|
sendJson(response, 400, { error: "This table does not support setRow." });
|
|
return;
|
|
}
|
|
const body = requireRecord(await readJsonBody(request), "set-row body must be an object.");
|
|
const rowIdentifier = requireString(Reflect.get(body, "rowIdentifier"), "rowIdentifier must be a string.");
|
|
const rowData = requireJsonValue(Reflect.get(body, "rowData"), "rowData must be valid JSON.");
|
|
if (!isRecord(rowData)) {
|
|
throw new StackAssertionError("rowData must be a JSON object.");
|
|
}
|
|
const executionContext = createBulldozerExecutionContext();
|
|
const metrics = await executeStatements(record.table.setRow(
|
|
executionContext,
|
|
rowIdentifier,
|
|
{ type: "expression", sql: quoteSqlJsonbLiteral(rowData).sql },
|
|
));
|
|
sendJson(response, 200, { ok: true, metrics });
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && pathParts[3] === "delete-row") {
|
|
if (!isStudioStoredTable(record.table)) {
|
|
sendJson(response, 400, { error: "This table does not support deleteRow." });
|
|
return;
|
|
}
|
|
const body = requireRecord(await readJsonBody(request), "delete-row body must be an object.");
|
|
const rowIdentifier = requireString(Reflect.get(body, "rowIdentifier"), "rowIdentifier must be a string.");
|
|
const executionContext = createBulldozerExecutionContext();
|
|
const metrics = await executeStatements(record.table.deleteRow(executionContext, rowIdentifier));
|
|
sendJson(response, 200, { ok: true, metrics });
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (method === "GET" && pathname === "/api/raw/node") {
|
|
const pathParam = requestUrl.searchParams.get("path") ?? "[]";
|
|
const parsedPath = JSON.parse(pathParam);
|
|
const pathSegments = requireStringArray(parsedPath, "path must be a string[]");
|
|
const node = await getRawNode(pathSegments);
|
|
sendJson(response, 200, node);
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && pathname === "/api/raw/upsert") {
|
|
const body = requireRecord(await readJsonBody(request), "raw upsert body must be an object.");
|
|
const pathSegments = requireStringArray(Reflect.get(body, "pathSegments"), "pathSegments must be a string[]");
|
|
const value = requireJsonValue(Reflect.get(body, "value") ?? null, "value must be valid JSON.");
|
|
const keyPathSql = keyPathSqlLiteral(pathSegments);
|
|
await retryTransaction(globalPrismaClient, async (tx) => {
|
|
await tx.$executeRawUnsafe(`SET LOCAL jit = off`);
|
|
await tx.$executeRawUnsafe(`SELECT pg_advisory_xact_lock(${BULLDOZER_LOCK_ID})`);
|
|
await tx.$executeRawUnsafe(`
|
|
WITH "targetPath" AS (
|
|
SELECT ${keyPathSql} AS "path"
|
|
)
|
|
INSERT INTO "BulldozerStorageEngine" ("id", "keyPath", "value")
|
|
SELECT
|
|
gen_random_uuid(),
|
|
"targetPath"."path"[1:"prefixes"."prefixLength"] AS "keyPath",
|
|
'null'::jsonb AS "value"
|
|
FROM "targetPath"
|
|
CROSS JOIN LATERAL generate_series(0, GREATEST(cardinality("targetPath"."path") - 1, 0)) AS "prefixes"("prefixLength")
|
|
ON CONFLICT ("keyPath") DO NOTHING
|
|
`);
|
|
await tx.$executeRawUnsafe(`
|
|
INSERT INTO "BulldozerStorageEngine" ("id", "keyPath", "value")
|
|
VALUES (gen_random_uuid(), ${keyPathSql}, ${quoteSqlJsonbLiteral(value).sql})
|
|
ON CONFLICT ("keyPath") DO UPDATE
|
|
SET "value" = EXCLUDED."value"
|
|
`);
|
|
});
|
|
sendJson(response, 200, { ok: true });
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && pathname === "/api/raw/delete") {
|
|
const body = requireRecord(await readJsonBody(request), "raw delete body must be an object.");
|
|
const pathSegments = requireStringArray(Reflect.get(body, "pathSegments"), "pathSegments must be a string[]");
|
|
if (
|
|
pathSegments.length === 0
|
|
|| (pathSegments.length === 1 && pathSegments[0] === "table")
|
|
) {
|
|
throw new StackAssertionError("Deleting reserved root paths is not allowed.");
|
|
}
|
|
await retryTransaction(globalPrismaClient, async (tx) => {
|
|
await tx.$executeRawUnsafe(`SET LOCAL jit = off`);
|
|
await tx.$executeRawUnsafe(`SELECT pg_advisory_xact_lock(${BULLDOZER_LOCK_ID})`);
|
|
await tx.$executeRawUnsafe(`
|
|
DELETE FROM "BulldozerStorageEngine"
|
|
WHERE "keyPath" = ${keyPathSqlLiteral(pathSegments)}
|
|
`);
|
|
});
|
|
sendJson(response, 200, { ok: true });
|
|
return;
|
|
}
|
|
|
|
sendJson(response, 404, { error: `Route not found: ${method} ${pathname}` });
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
await rebindInitializedDerivedTables();
|
|
|
|
const server = http.createServer((request, response) => {
|
|
handleRequest(request, response).then(
|
|
() => undefined,
|
|
(error) => {
|
|
console.error(error);
|
|
const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
sendJson(response, 500, { error: message });
|
|
},
|
|
);
|
|
});
|
|
|
|
server.listen(STUDIO_PORT, STUDIO_HOST, () => {
|
|
console.log(`Bulldozer Studio running on http://${STUDIO_HOST}:${STUDIO_PORT}`);
|
|
});
|
|
|
|
const shutdown = async () => {
|
|
server.close();
|
|
};
|
|
process.on("SIGINT", () => {
|
|
shutdown().then(() => process.exit(0), () => process.exit(1));
|
|
});
|
|
process.on("SIGTERM", () => {
|
|
shutdown().then(() => process.exit(0), () => process.exit(1));
|
|
});
|
|
}
|
|
|
|
main().then(
|
|
() => undefined,
|
|
(error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
},
|
|
);
|