From bda533d89edc19550d778a4d98e59dfb3d092cb9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 07:19:59 +0000 Subject: [PATCH] refactor(lokee): slim inspector, inspect payload, and revert helpers Drop the extra HEAD fields from inspect (last growth row is HEAD), merge column mutations into the single change timeline, and collapse revert UI state into one object. Hydrate reuses the blueprint container check; planRevert and the revert routes share less boilerplate. Co-authored-by: huy.phan9 --- apps/web/src/backend/api/routes.ts | 50 +-- .../modules/lokee-weave.module.test.ts | 2 +- .../src/backend/modules/lokee-weave.module.ts | 141 +++---- apps/web/src/frontend/api/lokeeApi.ts | 54 ++- .../lokee-weave/LokeeObjectInspector.test.tsx | 16 - .../lokee-weave/LokeeObjectInspector.tsx | 363 +++++++++--------- .../sql/src/modules/lokee-weave/blueprint.ts | 4 + .../sql/src/modules/lokee-weave/hydrate.ts | 43 +-- 8 files changed, 288 insertions(+), 385 deletions(-) diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index 89856f5..64367d7 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -997,17 +997,6 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt res.json(result); }); - const publicRevertPlan = (plan: Awaited>) => { - if (!plan) return null; - return { - fromVersion: plan.fromVersion, - toVersion: plan.toVersion, - alreadyAtTarget: plan.alreadyAtTarget, - reversal: plan.reversal, - statements: plan.statements, - }; - }; - router.get( '/lokee/databases/:id/revert/plan', requirePermissions('schema.browse'), @@ -1026,7 +1015,8 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt res.status(404).json({ error: 'Version not found' }); return; } - res.json(publicRevertPlan(plan)); + const { steps: _steps, ...published } = plan; + res.json(published); } ); @@ -1058,9 +1048,9 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt res.status(404).json({ error: 'Version not found' }); return; } - const published = publicRevertPlan(plan); - if (plan.alreadyAtTarget) { - res.json({ ok: true, alreadyAtTarget: true, ...published }); + const { steps, ...published } = plan; + if (plan.alreadyAtTarget || steps.length === 0) { + res.json({ ok: true, ...published, alreadyAtTarget: true }); return; } if (plan.reversal.risk === 'blocked') { @@ -1081,30 +1071,22 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt }); return; } - if (plan.steps.length === 0) { - res.json({ ok: true, alreadyAtTarget: true, ...published }); - return; - } - let success = false; - let executeError: string | undefined; + let failed = true; + let executeError = 'Revert failed'; try { - await migrationModule.execute(dialect, option, schema, plan.steps, (event) => { + await migrationModule.execute(dialect, option, schema, steps, (event) => { if (event.type === 'done') { - success = event.success; - executeError = event.error; + failed = !event.success; + if (event.error) executeError = event.error; } }); } catch (error: unknown) { - success = false; + failed = true; executeError = error instanceof Error ? error.message : 'Revert failed'; } - if (!success) { - res.status(500).json({ - ok: false, - error: executeError ?? 'Revert failed', - ...published, - }); + if (failed) { + res.status(500).json({ ok: false, error: executeError, ...published }); return; } @@ -1112,12 +1094,10 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt const capture = await captureLiveSchema(userId, { dialect, option, schema }, 'revert'); res.json({ ok: true, capture, ...published }); } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'capture failed'; res.status(500).json({ ok: false, - error: - error instanceof Error - ? `Schema reverted but capture failed: ${error.message}` - : 'Schema reverted but capture failed', + error: `Schema reverted but capture failed: ${message}`, ...published, }); } diff --git a/apps/web/src/backend/modules/lokee-weave.module.test.ts b/apps/web/src/backend/modules/lokee-weave.module.test.ts index b75c06a..e89df09 100644 --- a/apps/web/src/backend/modules/lokee-weave.module.test.ts +++ b/apps/web/src/backend/modules/lokee-weave.module.test.ts @@ -483,7 +483,7 @@ describe('inspectObject', () => { const v2 = await weave.capture(USER, { ...IDENTITY, tables: [widened], source: 'migrate' }); const inspect = await weave.inspectObject(USER, v2.databaseId, v2.versionId, 'table:CUSTOMER'); - expect(inspect?.headVersionId).toBe(v2.versionId); + expect(inspect?.growth.at(-1)?.versionId).toBe(v2.versionId); expect(inspect?.columnMutations.map((c) => c.columnName).sort()).toEqual(['email', 'id', 'phone']); const email = inspect?.columnMutations.find((c) => c.columnName === 'email'); expect(email?.events.map((e) => e.operation)).toEqual(['ADD', 'MODIFY']); diff --git a/apps/web/src/backend/modules/lokee-weave.module.ts b/apps/web/src/backend/modules/lokee-weave.module.ts index 63a1245..49c50eb 100644 --- a/apps/web/src/backend/modules/lokee-weave.module.ts +++ b/apps/web/src/backend/modules/lokee-weave.module.ts @@ -137,8 +137,6 @@ export interface ObjectInspectResult { growth: ContainerGrowthPoint[]; /** Column ADD / MODIFY / DELETE across versions, when the focus is a table. */ columnMutations: ColumnMutation[]; - headVersionId: string | null; - headVersionNumber: number | null; } export interface RevertPlanResult { @@ -242,6 +240,10 @@ function toCanonical(key: string, object: VersionedObject): CanonicalObject { }; } +function canonicalList(objects: Map): CanonicalObject[] { + return [...objects.entries()].map(([key, object]) => toCanonical(key, object)); +} + /** `LIKE` prefix for children of one owner. `!` is the ESCAPE character. */ function likeOwnerPrefix(kind: string, owner: string): string { const escaped = owner.replace(/!/g, '!!').replace(/%/g, '!%').replace(/_/g, '!_'); @@ -995,36 +997,15 @@ export class LokeeWeaveStore { const atVersion = await this.objectsAtVersion(userId, databaseId, versionId); const stored = new Map(); - for (const [key, object] of atVersion) { - stored.set(key, { - key, - type: object.type, - name: object.name, - hash: object.hash, - body: object.body, - sourceText: object.sourceText, - lineCount: object.lineCount, - firstSeenAt: object.firstSeenAt, - }); - } - const blueprint = assembleBlueprint(objectKey, stored); - const history = await this.objectHistory(userId, databaseId, objectKey); + for (const [key, object] of atVersion) stored.set(key, { key, ...object }); const owner = objectKeyOwner(objectKey); - const growth = await this.containerGrowth(userId, databaseId, owner); const kind = objectKeyKind(objectKey); - const columnMutations = - kind === 'table' || kind === 'mqt' || kind === 'view' - ? await this.columnMutations(userId, databaseId, owner) - : []; - const versions = await this.listVersions(userId, databaseId, 1); - const head = versions[0] ?? null; + const isTable = kind === 'table' || kind === 'mqt' || kind === 'view'; return { - blueprint, - history, - growth, - columnMutations, - headVersionId: head?.id ?? null, - headVersionNumber: head?.number ?? null, + blueprint: assembleBlueprint(objectKey, stored), + history: await this.objectHistory(userId, databaseId, objectKey), + growth: await this.containerGrowth(userId, databaseId, owner), + columnMutations: isTable ? await this.columnMutations(userId, databaseId, owner) : [], }; } @@ -1049,23 +1030,20 @@ export class LokeeWeaveStore { const toVersion = versions.find((v) => v.id === toVersionId); if (!fromVersion || !toVersion) return null; - const emptyReversal = planReversal([]); - if (fromVersion.id === toVersion.id) { - return { - fromVersion, - toVersion, - alreadyAtTarget: true, - reversal: emptyReversal, - steps: [], - statements: [], - }; - } + const none: RevertPlanResult = { + fromVersion, + toVersion, + alreadyAtTarget: fromVersion.id === toVersion.id, + reversal: planReversal([]), + steps: [], + statements: [], + }; + if (none.alreadyAtTarget) return none; const current = await this.objectsAtVersion(userId, databaseId, fromVersion.id); const desired = await this.objectsAtVersion(userId, databaseId, toVersion.id); - const keys = new Set([...current.keys(), ...desired.keys()]); const entries: Array<{ key: string; current?: CanonicalObject; target?: CanonicalObject }> = []; - for (const key of keys) { + for (const key of new Set([...current.keys(), ...desired.keys()])) { const cur = current.get(key); const tgt = desired.get(key); if (cur && tgt && cur.hash === tgt.hash) continue; @@ -1075,43 +1053,34 @@ export class LokeeWeaveStore { target: tgt ? toCanonical(key, tgt) : undefined, }); } - const reversal = planReversal(entries); - let resolvedDialect = dialect; - let resolvedSchema = schema; - if (!resolvedDialect) { + let dialectName = dialect; + let schemaName = schema; + if (!dialectName) { const db = await store.get<{ dialect: string; schema: string | null }>( 'SELECT dialect, "schema" FROM lokee_databases WHERE id = ? AND user_id = ?', [databaseId, userId] ); - resolvedDialect = db?.dialect; - if (resolvedSchema == null) resolvedSchema = db?.schema ?? undefined; + dialectName = db?.dialect; + schemaName ??= db?.schema ?? undefined; } - let steps: MigrationStep[] = []; - let statements: string[] = []; - if (resolvedDialect) { - const currentTables = hydrateTableSchemas( - [...current.entries()].map(([key, object]) => toCanonical(key, object)) - ); - const targetTables = hydrateTableSchemas( - [...desired.entries()].map(([key, object]) => toCanonical(key, object)) - ); - const migration = await buildRevertMigration(currentTables, targetTables, resolvedDialect, { - targetSchema: resolvedSchema, - sourceSchema: resolvedSchema, - }); - steps = migration.steps; - statements = migration.statements; - } + const migration = dialectName + ? await buildRevertMigration( + hydrateTableSchemas(canonicalList(current)), + hydrateTableSchemas(canonicalList(desired)), + dialectName, + { targetSchema: schemaName, sourceSchema: schemaName } + ) + : { steps: [] as MigrationStep[], statements: [] as string[] }; return { fromVersion, toVersion, alreadyAtTarget: false, - reversal, - steps, - statements, + reversal: planReversal(entries), + steps: migration.steps, + statements: migration.statements, }; } @@ -1123,15 +1092,7 @@ export class LokeeWeaveStore { const store = await this.store(); if (!(await this.assertOwned(store, userId, databaseId))) return []; - const rows = await store.all<{ - version_id: string; - version_number: number; - created_at: string; - source: CaptureSource; - operation: 'ADD' | 'MODIFY' | 'DELETE'; - object_hash: string | null; - previous_hash: string | null; - }>( + const rows = await store.all( `SELECT vo.operation, vo.object_hash, vo.previous_hash, v.id AS version_id, v.version_number, v.created_at, v.source FROM lokee_version_objects vo @@ -1168,23 +1129,23 @@ export class LokeeWeaveStore { const bodies = await this.loadObjectBodies(store, rows); const byKey = new Map>(); for (const row of rows) { - const list = byKey.get(row.object_key); - if (list) list.push(row); - else byKey.set(row.object_key, [row]); + const list = byKey.get(row.object_key) ?? []; + list.push(row); + byKey.set(row.object_key, list); } - const out: ColumnMutation[] = []; - for (const [objectKey, group] of byKey) { + return [...byKey.entries()].flatMap(([objectKey, group]) => { const events = historyEntriesFromRows(group, bodies); - if (events.length === 0) continue; - const fromBody = events.find((e) => typeof e.body?.name === 'string')?.body?.name; - const columnName = - typeof fromBody === 'string' - ? fromBody - : objectKey.slice(objectKey.lastIndexOf('.') + 1).toLowerCase(); - out.push({ objectKey, columnName, events }); - } - return out; + if (events.length === 0) return []; + const named = events.find((e) => typeof e.body?.name === 'string')?.body?.name; + return [ + { + objectKey, + columnName: typeof named === 'string' ? named : objectKey.slice(objectKey.lastIndexOf('.') + 1), + events, + }, + ]; + }); } private async loadObjectBodies( diff --git a/apps/web/src/frontend/api/lokeeApi.ts b/apps/web/src/frontend/api/lokeeApi.ts index 7f56550..2496e04 100644 --- a/apps/web/src/frontend/api/lokeeApi.ts +++ b/apps/web/src/frontend/api/lokeeApi.ts @@ -11,7 +11,7 @@ */ import type { ConnectionRef } from './schemaApi'; import type { VersionGraphDTO } from '../components/lokee-weave/graphTypes'; -import { getApiBase, parseJsonResponse } from './apiBase'; +import { getApiBase, parseJsonBody, parseJsonResponse } from './apiBase'; export interface LokeeDatabase { id: string; @@ -120,6 +120,22 @@ export async function updateLokeeVersionMeta( return body.version; } +export interface LokeeHistoryEvent { + versionId: string; + versionNumber: number; + createdAt: string; + source: string; + operation: 'ADD' | 'MODIFY' | 'DELETE'; + hash?: string; + previousHash?: string; + body?: Record; + previousBody?: Record; + lineCount?: number | null; + previousLineCount?: number | null; + firstSeenAt?: string | null; + reused: boolean; +} + export interface LokeeInspectResult { blueprint: { focusKey: string; @@ -131,21 +147,7 @@ export interface LokeeInspectResult { triggers: LokeeStoredObject[]; primaryKey: LokeeStoredObject | null; }; - history: Array<{ - versionId: string; - versionNumber: number; - createdAt: string; - source: string; - operation: 'ADD' | 'MODIFY' | 'DELETE'; - hash?: string; - previousHash?: string; - body?: Record; - previousBody?: Record; - lineCount?: number | null; - previousLineCount?: number | null; - firstSeenAt?: string | null; - reused: boolean; - }>; + history: LokeeHistoryEvent[]; growth: Array<{ versionId: string; versionNumber: number; @@ -159,10 +161,8 @@ export interface LokeeInspectResult { columnMutations: Array<{ objectKey: string; columnName: string; - events: LokeeInspectResult['history']; + events: LokeeHistoryEvent[]; }>; - headVersionId: string | null; - headVersionNumber: number | null; } export interface LokeeStoredObject { @@ -256,19 +256,13 @@ export async function executeLokeeRevert( body: JSON.stringify(body), } ); - const data = (await res.json()) as LokeeRevertPlan & { - ok?: boolean; - error?: string; - code?: string; - capture?: CaptureResult; - alreadyAtTarget?: boolean; - }; - if (res.ok) { - return { ok: true, ...data } as LokeeRevertResult; - } + const data = await parseJsonBody< + LokeeRevertPlan & { ok?: boolean; error?: string; code?: string; capture?: CaptureResult } + >(res); + if (res.ok) return { ...data, ok: true as const }; const code = data.code === 'blocked' || data.code === 'confirm_lossy' ? data.code : 'failed'; throw new LokeeRevertError( - typeof data.error === 'string' ? data.error : res.statusText || 'Revert failed', + data.error || res.statusText || 'Revert failed', code, data.fromVersion ? data : undefined ); diff --git a/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.test.tsx b/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.test.tsx index a4f63eb..4408044 100644 --- a/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.test.tsx +++ b/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.test.tsx @@ -16,16 +16,6 @@ vi.mock('../../api/lokeeApi', () => ({ inspectLokeeObject: (...args: unknown[]) => inspectLokeeObject(...args), planLokeeRevert: (...args: unknown[]) => planLokeeRevert(...args), executeLokeeRevert: (...args: unknown[]) => executeLokeeRevert(...args), - LokeeRevertError: class LokeeRevertError extends Error { - code: string; - plan?: unknown; - constructor(message: string, code: string, plan?: unknown) { - super(message); - this.name = 'LokeeRevertError'; - this.code = code; - this.plan = plan; - } - }, })); vi.mock('../../store/toastStore', () => ({ toast: vi.fn() })); @@ -190,8 +180,6 @@ describe('LokeeObjectInspector', () => { ], }, ], - headVersionId: 'v2', - headVersionNumber: 2, }); render( @@ -249,8 +237,6 @@ describe('LokeeObjectInspector', () => { }, ], columnMutations: [], - headVersionId: 'v2', - headVersionNumber: 2, }); planLokeeRevert.mockResolvedValue({ fromVersion: { id: 'v2', number: 2 }, @@ -307,8 +293,6 @@ describe('LokeeObjectInspector', () => { history: [], growth: [], columnMutations: [], - headVersionId: null, - headVersionNumber: null, }); const onClose = vi.fn(); render(); diff --git a/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx b/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx index e3d205b..9e18967 100644 --- a/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx +++ b/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx @@ -3,8 +3,8 @@ * Copyright 2024-2026 Huy Phan * SPDX-License-Identifier: Apache-2.0 * - * Read-only inspector for a Lokee graph node: table blueprint, column - * mutations across v1…vN, growth, and revert-to-version. + * Inspector for a Lokee graph node: blueprint at a version, column mutations + * across v1…vN, growth, and revert-to-version. */ import React, { useEffect, useState } from 'react'; import { parseTypeText } from '@foxschema/sql'; @@ -12,14 +12,14 @@ import { Loader2, RotateCcw, X } from 'lucide-react'; import { executeLokeeRevert, inspectLokeeObject, - LokeeRevertError, planLokeeRevert, + type LokeeHistoryEvent, type LokeeInspectResult, type LokeeRevertPlan, type LokeeStoredObject, } from '../../api/lokeeApi'; import { getSessionPassword } from '../../lib/sessionPasswords'; -import { objectStyle, OPERATION_GLYPH, riskStyle } from '../../lib/lokeeColors'; +import { objectStyle, riskStyle } from '../../lib/lokeeColors'; import { toast } from '../../store/toastStore'; import { shortHash, type SchemaObjectNodeData } from './graphTypes'; import { SQL_ICON_STROKE } from '../sql-editor/sqlIconStyle'; @@ -28,14 +28,19 @@ export interface LokeeObjectInspectorProps { databaseId: string; selected: SchemaObjectNodeData; onClose: () => void; - /** Saved credential used to apply reverse DDL. */ captureConnectionId?: string; - /** Jump the inspector to the same object at another version. */ onSelectVersion?: (versionId: string) => void; - /** After a successful revert, reload the graph. */ onReverted?: () => void; } +interface RevertUi { + versionId: string; + plan: LokeeRevertPlan | null; + busy: boolean; + error: string | null; + confirmLossy: boolean; +} + function asString(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null; } @@ -109,34 +114,29 @@ function ChildTable({ ); } -function HistoryEvent({ - point, -}: { - point: LokeeInspectResult['history'][number]; -}): React.ReactElement { - const glyph = OPERATION_GLYPH[point.operation] ?? point.operation; +function HistoryEvent({ point }: { point: LokeeHistoryEvent }): React.ReactElement { + const typeChange = + point.body?.dataType != null + ? point.previousBody?.dataType + ? `${typeLabel(point.previousBody)} → ${typeLabel(point.body)}` + : typeLabel(point.body) + : null; + const lines = + point.lineCount != null + ? point.previousLineCount != null && point.previousLineCount !== point.lineCount + ? `${point.previousLineCount} → ${point.lineCount} lines` + : `${point.lineCount} lines` + : null; return (
  • - v{point.versionNumber} · {glyph} {point.operation} + v{point.versionNumber} · {point.operation} {formatWhen(point.createdAt)}
    - {point.body?.dataType != null && ( -
    - {point.previousBody?.dataType - ? `${typeLabel(point.previousBody)} → ${typeLabel(point.body)}` - : typeLabel(point.body)} -
    - )} - {point.lineCount != null && ( -
    - {point.previousLineCount != null && point.previousLineCount !== point.lineCount - ? `${point.previousLineCount} → ${point.lineCount} lines` - : `${point.lineCount} lines`} -
    - )} + {typeChange &&
    {typeChange}
    } + {lines &&
    {lines}
    } {point.reused && (
    Reused hash — stored once (pointer)
    )} @@ -144,6 +144,118 @@ function HistoryEvent({ ); } +function RevertCard({ + connectionId, + revert, + onConfirmLossy, + onExecute, + onCancel, +}: { + connectionId?: string; + revert: RevertUi; + onConfirmLossy: (checked: boolean) => void; + onExecute: () => void; + onCancel: () => void; +}): React.ReactElement { + const plan = revert.plan; + const blocked = plan?.reversal.risk === 'blocked'; + const lossy = plan?.reversal.risk === 'lossy'; + const canRun = + Boolean(plan) && + !revert.busy && + !blocked && + Boolean(connectionId) && + (!lossy || revert.confirmLossy) && + !plan?.alreadyAtTarget; + + return ( +
    +

    Revert schema

    + {revert.busy && !plan && ( +
    + + Planning reverse DDL… +
    + )} + {revert.error &&

    {revert.error}

    } + {plan && ( + <> +

    + Apply reverse DDL so the live schema matches v{plan.toVersion.number}, then record a new + version. +

    + {plan.alreadyAtTarget ? ( +

    Already at this version.

    + ) : ( + <> +
    + {riskStyle(plan.reversal.risk).label} + {plan.reversal.lossyCount > 0 ? ` · ${plan.reversal.lossyCount} lossy` : ''} + {plan.reversal.blockedCount > 0 ? ` · ${plan.reversal.blockedCount} blocked` : ''} +
    +
      + {plan.reversal.verdicts.slice(0, 12).map((v) => ( +
    • + {v.summary} + {v.dataLoss ? ` — ${v.dataLoss}` : ''} +
    • + ))} +
    + {plan.statements.length > 0 && ( +
    +                  {plan.statements.join('\n')}
    +                
    + )} + {lossy && ( + + )} +
    + + +
    + {!connectionId && ( +

    + Pick a credential in History to apply the revert. +

    + )} + + )} + + )} +
    + ); +} + export function LokeeObjectInspector({ databaseId, selected, @@ -155,16 +267,13 @@ export function LokeeObjectInspector({ const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [data, setData] = useState(null); - const [revertFor, setRevertFor] = useState(null); - const [revertPlan, setRevertPlan] = useState(null); - const [revertBusy, setRevertBusy] = useState(false); - const [revertError, setRevertError] = useState(null); - const [confirmLossy, setConfirmLossy] = useState(false); + const [revert, setRevert] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); setError(null); + setRevert(null); void inspectLokeeObject(databaseId, selected.versionId, selected.objectKey) .then((result) => { if (!cancelled) setData(result); @@ -180,48 +289,37 @@ export function LokeeObjectInspector({ }; }, [databaseId, selected.versionId, selected.objectKey]); - useEffect(() => { - setRevertFor(null); - setRevertPlan(null); - setRevertError(null); - setConfirmLossy(false); - }, [selected.versionId, selected.objectKey]); - const focus = data?.blueprint.object ?? data?.blueprint.container; const source = focus?.sourceText ?? (typeof focus?.body.definition === 'string' ? focus.body.definition : null); const style = objectStyle(selected.objectType); - const headVersionId = data?.headVersionId ?? null; const mutations = data?.columnMutations ?? []; + const headVersionId = data?.growth.at(-1)?.versionId ?? null; const openRevert = async (versionId: string) => { - setRevertFor(versionId); - setRevertPlan(null); - setRevertError(null); - setConfirmLossy(false); - setRevertBusy(true); + setRevert({ versionId, plan: null, busy: true, error: null, confirmLossy: false }); try { const plan = await planLokeeRevert(databaseId, versionId); - setRevertPlan(plan); + setRevert((prev) => (prev?.versionId === versionId ? { ...prev, plan, busy: false } : prev)); } catch (err: unknown) { - setRevertError(err instanceof Error ? err.message : 'Failed to plan revert'); - } finally { - setRevertBusy(false); + setRevert((prev) => + prev?.versionId === versionId + ? { ...prev, busy: false, error: err instanceof Error ? err.message : 'Failed to plan revert' } + : prev + ); } }; const runRevert = async () => { - if (!revertFor || !captureConnectionId || !revertPlan) return; - if (revertPlan.reversal.risk === 'blocked') return; - setRevertBusy(true); - setRevertError(null); + if (!revert?.plan || !captureConnectionId) return; + setRevert((prev) => (prev ? { ...prev, busy: true, error: null } : prev)); try { const result = await executeLokeeRevert(databaseId, { - toVersionId: revertFor, + toVersionId: revert.versionId, connectionId: captureConnectionId, password: getSessionPassword(captureConnectionId) || undefined, - confirmLossy: revertPlan.reversal.risk === 'lossy' ? confirmLossy : undefined, + confirmLossy: revert.plan.reversal.risk === 'lossy' ? revert.confirmLossy : undefined, }); toast({ tone: 'success', @@ -232,18 +330,12 @@ export function LokeeObjectInspector({ ? `Recorded v${result.capture.versionNumber} · ${result.capture.changeCount} object change(s)` : 'Live schema matches that version.', }); - setRevertFor(null); - setRevertPlan(null); + setRevert(null); onReverted?.(); } catch (err: unknown) { - if (err instanceof LokeeRevertError && err.code === 'confirm_lossy' && err.plan) { - setRevertPlan(err.plan); - setRevertError('This revert destroys data. Confirm below to continue.'); - } else { - setRevertError(err instanceof Error ? err.message : 'Revert failed'); - } - } finally { - setRevertBusy(false); + setRevert((prev) => + prev ? { ...prev, busy: false, error: err instanceof Error ? err.message : 'Revert failed' } : prev + ); } }; @@ -325,7 +417,8 @@ export function LokeeObjectInspector({ {data.growth.length > 0 && (

    - Table growth · v1…v{data.growth[data.growth.length - 1]?.versionNumber} + Table growth · v{data.growth[0]?.versionNumber}…v + {data.growth[data.growth.length - 1]?.versionNumber}

      {data.growth.map((g) => { @@ -346,11 +439,11 @@ export function LokeeObjectInspector({ title={`Show this object at v${g.versionNumber}`} > v{g.versionNumber} - {isHead ? ( + {isHead && ( head - ) : null} + )} {g.columns} cols · {g.indexes} idx · {g.triggers} trg @@ -374,12 +467,12 @@ export function LokeeObjectInspector({
    )} - {mutations.length > 0 && ( -
    -

    - Column mutations -

    -
    +
    +

    + Change timeline +

    + {mutations.length > 0 ? ( +
    {mutations.map((col) => (
    {col.columnName}
    @@ -391,14 +484,7 @@ export function LokeeObjectInspector({
    ))}
    -
    - )} - -
    -

    - Change timeline -

    - {data.history.length === 0 ? ( + ) : data.history.length === 0 ? (

    No recorded changes for this object.

    ) : (
      @@ -409,107 +495,16 @@ export function LokeeObjectInspector({ )}
    - {revertFor && ( -
    -

    - Revert schema -

    - {revertBusy && !revertPlan && ( -
    - - Planning reverse DDL… -
    - )} - {revertError &&

    {revertError}

    } - {revertPlan && ( - <> -

    - Live schema is v{revertPlan.fromVersion.number}. Revert applies reverse DDL so - it matches v{revertPlan.toVersion.number}, then records a new version. -

    - {revertPlan.alreadyAtTarget ? ( -

    Already at this version.

    - ) : ( - <> -
    - {riskStyle(revertPlan.reversal.risk).label} - {revertPlan.reversal.lossyCount > 0 - ? ` · ${revertPlan.reversal.lossyCount} lossy` - : ''} - {revertPlan.reversal.blockedCount > 0 - ? ` · ${revertPlan.reversal.blockedCount} blocked` - : ''} -
    -
      - {revertPlan.reversal.verdicts.slice(0, 12).map((v) => ( -
    • - {v.summary} - {v.dataLoss ? ` — ${v.dataLoss}` : ''} -
    • - ))} -
    - {revertPlan.statements.length > 0 && ( -
    -                            {revertPlan.statements.join('\n')}
    -                          
    - )} - {revertPlan.reversal.risk === 'lossy' && ( - - )} -
    - - -
    - {!captureConnectionId && ( -

    - Pick a credential in History to apply the revert. -

    - )} - - )} - - )} -
    + {revert && ( + + setRevert((prev) => (prev ? { ...prev, confirmLossy: checked } : prev)) + } + onExecute={() => void runRevert()} + onCancel={() => setRevert(null)} + /> )} )} diff --git a/packages/sql/src/modules/lokee-weave/blueprint.ts b/packages/sql/src/modules/lokee-weave/blueprint.ts index aa7b903..b2c5b4f 100644 --- a/packages/sql/src/modules/lokee-weave/blueprint.ts +++ b/packages/sql/src/modules/lokee-weave/blueprint.ts @@ -37,6 +37,10 @@ const CONTAINER_TYPES: readonly LokeeObjectType[] = [ 'type', ]; +export function isLokeeContainerType(type: string): boolean { + return (CONTAINER_TYPES as readonly string[]).includes(type); +} + const CHILD_TYPES = { column: 'columns', index: 'indexes', diff --git a/packages/sql/src/modules/lokee-weave/hydrate.ts b/packages/sql/src/modules/lokee-weave/hydrate.ts index 8a6a1fd..e5665df 100644 --- a/packages/sql/src/modules/lokee-weave/hydrate.ts +++ b/packages/sql/src/modules/lokee-weave/hydrate.ts @@ -18,19 +18,8 @@ import type { TableSchema, TriggerInfo, } from '../../interfaces/schema.interface.js'; -import type { CanonicalObject, LokeeObjectType } from './canonical.js'; -import { objectKeyKind, objectKeyOwner } from './blueprint.js'; - -const CONTAINER_TYPES: ReadonlySet = new Set([ - 'table', - 'view', - 'mqt', - 'function', - 'procedure', - 'trigger', - 'sequence', - 'type', -]); +import type { CanonicalObject } from './canonical.js'; +import { isLokeeContainerType, objectKeyOwner } from './blueprint.js'; function asString(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined; @@ -45,6 +34,12 @@ function asStringArray(value: unknown): string[] { return value.filter((item): item is string => typeof item === 'string'); } +function asRecord(value: unknown): T | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as T) + : undefined; +} + function columnNameFromKey(key: string): string { const rest = key.slice(key.indexOf(':') + 1); const dot = rest.lastIndexOf('.'); @@ -112,13 +107,14 @@ export function hydrateTableSchemas(objects: readonly CanonicalObject[]): TableS const tables: TableSchema[] = []; for (const group of byOwner.values()) { - const container = group.find((item) => CONTAINER_TYPES.has(item.type)); + const container = group.find((item) => isLokeeContainerType(item.type)); if (!container) continue; const pk = group.find((item) => item.type === 'primary_key'); const pkColumns = pk ? asStringArray(pk.body.columns) : []; const name = asString(container.body.name) ?? objectKeyOwner(container.key); const objectType = (asString(container.body.objectType) as DbObjectType | undefined) ?? 'TABLE'; + const kind = container.body.functionKind; tables.push({ name, @@ -128,24 +124,13 @@ export function hydrateTableSchemas(objects: readonly CanonicalObject[]): TableS indices: group.filter((item) => item.type === 'index').map(toIndex), foreignKeys: group.filter((item) => item.type === 'foreign_key').map(toForeignKey), primaryKey: pkColumns.length > 0 ? { columns: pkColumns } : undefined, - triggers: group - .filter((item) => item.type === 'trigger' && objectKeyKind(item.key) === 'trigger' && item !== container) - .map(toTrigger), - sequence: - container.body.sequence && typeof container.body.sequence === 'object' - ? (container.body.sequence as TableSchema['sequence']) - : undefined, - userType: - container.body.userType && typeof container.body.userType === 'object' - ? (container.body.userType as TableSchema['userType']) - : undefined, + triggers: group.filter((item) => item.type === 'trigger' && item !== container).map(toTrigger), + sequence: asRecord(container.body.sequence), + userType: asRecord(container.body.userType), parameters: Array.isArray(container.body.parameters) ? (container.body.parameters as TableSchema['parameters']) : undefined, - functionKind: - container.body.functionKind === 'scalar' || container.body.functionKind === 'table' - ? container.body.functionKind - : undefined, + functionKind: kind === 'scalar' || kind === 'table' ? kind : undefined, tablespace: asString(container.body.tablespace), }); }