diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index 89856f5f..64367d7c 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 b75c06a5..e89df09b 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 63a12452..49c50ebb 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 7f56550c..2496e04f 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 a4f63ebb..4408044d 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 e3d205b0..9e189677 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 aa7b903b..b2c5b4f8 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 8a6a1fd2..e5665dff 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), }); }