From 1bc23940b79ee257480cb6389a64e2e902f4d317 Mon Sep 17 00:00:00 2001 From: huyplb Date: Tue, 18 Aug 2026 16:04:55 -0600 Subject: [PATCH 1/6] fix(lokee): a revert only runs against the current database, and snapshots first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to the revert path, one of them a correctness bug. **What you review is now what runs.** The diff came from `compareLokeeVersions(original, target)` while the plan and the execute call went to `planLokeeRevert(original)` — which reverses from the *live head*, ignoring Target entirely. With an older version on Target the reader reviewed one script and Execute applied a different, usually larger one. Reverting restores the live database, so it is only coherent when Target is the newest version; anything else is refused, with a one-click "Use current database" to fix it. An open dialog now follows the picker instead of holding the pair it opened with, so that button visibly does something. **Snapshot before touching anything.** The revert route captures the live schema first. That leaves a version to come back to, but the reason that matters is correctness: `planRevert` reverses from the newest *captured* version, not from what is in the database, so a schema edited by hand since the last capture was being reversed against a picture that no longer existed. When the snapshot finds drift the request is refused (`schema_drifted`) rather than applied — the caller reviewed a plan built on the old head, and running a different one silently is the surprise this exists to stop. Verified live: a hand-added column is captured as its own version and the revert leaves the database untouched. **The version menus say which choices cannot be reverted.** The newest version on Original reads "current, nothing to restore"; an older version on Target reads "compare only". Both stay selectable — they are legitimate comparisons — but they no longer look identical to a choice that can run and then dead-end at a greyed-out button with the reason hidden in a tooltip. Co-Authored-By: Claude Opus 5 --- apps/web/src/backend/api/routes.ts | 38 +++++ apps/web/src/frontend/api/lokeeApi.ts | 3 +- .../lokee-weave/HistoryCompareBar.tsx | 14 +- .../components/lokee-weave/LokeeWeaveView.tsx | 32 +++- .../lokee-weave/VersionCompareModal.test.tsx | 143 +++++++++++++++++- .../lokee-weave/VersionCompareModal.tsx | 58 ++++++- .../src/frontend/lib/historyCompare.test.ts | 33 ++++ apps/web/src/frontend/lib/historyCompare.ts | 19 ++- apps/web/src/shared/lokee-wire.ts | 7 + 9 files changed, 339 insertions(+), 8 deletions(-) diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index 336f7198..e884c2fa 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -1144,6 +1144,44 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt return; } + /** + * Snapshot the live schema before touching it. + * + * Two reasons, and the second is the one that matters. It leaves a + * version to come back to — but it also makes the plan *correct*: + * `planRevert` reverses from the newest **captured** version, not from + * what is actually in the database. If someone changed the schema by + * hand since the last capture, the reverse DDL was being computed against + * a picture that no longer existed. + * + * So when this snapshot finds drift, the request is refused rather than + * applied. The caller reviewed a plan built on the old head; running a + * different one silently is exactly the surprise this is here to stop. + */ + let preSnapshot: Awaited>; + try { + preSnapshot = await captureLiveSchema(userId, { dialect, option, schema }, 'manual'); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'snapshot failed'; + res.status(500).json({ + ok: false, + error: `Could not snapshot the schema before reverting: ${message}`, + code: 'failed', + }); + return; + } + if (preSnapshot.changed) { + res.status(409).json({ + ok: false, + code: 'schema_drifted', + error: + `The live schema had changed since the last capture — snapshotted it as v${preSnapshot.versionNumber} ` + + `(${preSnapshot.changeCount} object change(s)). Review the diff against that version and run the revert again.`, + capture: preSnapshot, + }); + return; + } + const objectKeys = Array.isArray(body.objectKeys) ? body.objectKeys.map((k) => String(k).trim()).filter(Boolean) : undefined; diff --git a/apps/web/src/frontend/api/lokeeApi.ts b/apps/web/src/frontend/api/lokeeApi.ts index 438bf9d7..6a760641 100644 --- a/apps/web/src/frontend/api/lokeeApi.ts +++ b/apps/web/src/frontend/api/lokeeApi.ts @@ -183,7 +183,8 @@ export async function executeLokeeRevert( const code = data.code === 'blocked' || data.code === 'confirm_lossy' || - data.code === 'connection_mismatch' + data.code === 'connection_mismatch' || + data.code === 'schema_drifted' ? data.code : 'failed'; throw new LokeeRevertError( diff --git a/apps/web/src/frontend/components/lokee-weave/HistoryCompareBar.tsx b/apps/web/src/frontend/components/lokee-weave/HistoryCompareBar.tsx index 79bafbaf..2e6dccf1 100644 --- a/apps/web/src/frontend/components/lokee-weave/HistoryCompareBar.tsx +++ b/apps/web/src/frontend/components/lokee-weave/HistoryCompareBar.tsx @@ -81,7 +81,13 @@ export function HistoryCompareBar(): React.ReactElement { {newestFirst.length === 0 && } {newestFirst.map((v) => ( ))} @@ -129,9 +135,13 @@ export function HistoryCompareBar(): React.ReactElement { ) : ( )} + {/* A revert restores the live database, so anything other than + "Current database" here is a comparison only — the diff would not + be the DDL that runs. Said in the option rather than discovered at + a greyed-out Execute button. */} {olderTargets.map((v) => ( ))} diff --git a/apps/web/src/frontend/components/lokee-weave/LokeeWeaveView.tsx b/apps/web/src/frontend/components/lokee-weave/LokeeWeaveView.tsx index 91734082..bf5aecd3 100644 --- a/apps/web/src/frontend/components/lokee-weave/LokeeWeaveView.tsx +++ b/apps/web/src/frontend/components/lokee-weave/LokeeWeaveView.tsx @@ -28,7 +28,11 @@ import { toast } from '../../store/toastStore'; import { useSyncStore } from '../../store/useSyncStore'; import { useUiStore } from '../../store/uiStore'; import { useLokeeHistoryStore } from '../../store/lokeeHistoryStore'; -import { lokeeDatabaseLabel, resolveHistoryCompare } from '../../lib/historyCompare'; +import { + lokeeDatabaseLabel, + resolveHistoryCompare, + sortVersionsNewestFirst, +} from '../../lib/historyCompare'; import { SQL_ICON_STROKE } from '../sql-editor/sqlIconStyle'; export interface LokeeWeaveViewProps { @@ -80,6 +84,7 @@ export function LokeeWeaveView({ const captureRequest = useLokeeHistoryStore((s) => s.captureRequest); const refreshRequest = useLokeeHistoryStore((s) => s.refreshRequest); const targetVersionId = useLokeeHistoryStore((s) => s.targetVersionId); + const setTargetVersionId = useLokeeHistoryStore((s) => s.setTargetVersionId); const [databases, setDatabases] = useState([]); const [dto, setDto] = useState(EMPTY_DTO); @@ -92,6 +97,11 @@ export function LokeeWeaveView({ // Which pair the modal is showing. The two *sides* live in the history store, // because the picker that sets them is HistoryCompareBar up in the toolbar. const [comparePair, setComparePair] = useState<{ original: string; target: string } | null>(null); + /** Newest captured version — the only Target a revert can legally run against. */ + const latestVersionId = useMemo( + () => sortVersionsNewestFirst(dto?.versions ?? [])[0]?.id ?? null, + [dto?.versions] + ); const matchedTargetId = useMemo(() => { const host = (targetConfig.option.host ?? '').toLowerCase(); @@ -352,6 +362,22 @@ export function LokeeWeaveView({ } }, [compareRequest, compareVersionIds]); + /** + * Keep an open dialog on the sides the bar is showing. + * + * The pair used to be snapshotted when the dialog opened, so "Use current + * database" moved the picker behind the modal and the modal carried on + * refusing — a button that visibly did nothing. The guard on equality is what + * stops this from looping. + */ + useEffect(() => { + if (!comparePair || compareVersionIds.length !== 2) return; + const [original, target] = compareVersionIds as [string, string]; + setComparePair((prev) => + prev && (prev.original !== original || prev.target !== target) ? { original, target } : prev + ); + }, [comparePair, compareVersionIds]); + // Every hook above any early return — a rules-of-hooks crash has happened in // this codebase before. if (loading) { @@ -427,6 +453,10 @@ export function LokeeWeaveView({ databaseId={activeId} versionId={comparePair.original} againstVersionId={comparePair.target} + // Reverting restores the live database, so it is only coherent + // while Target is the newest version — the modal refuses otherwise. + latestVersionId={latestVersionId ?? undefined} + onRetargetToLatest={() => setTargetVersionId(null)} captureConnectionId={captureConnectionId || undefined} onReverted={refresh} onClose={() => setComparePair(null)} diff --git a/apps/web/src/frontend/components/lokee-weave/VersionCompareModal.test.tsx b/apps/web/src/frontend/components/lokee-weave/VersionCompareModal.test.tsx index 8511168e..853dd7d1 100644 --- a/apps/web/src/frontend/components/lokee-weave/VersionCompareModal.test.tsx +++ b/apps/web/src/frontend/components/lokee-weave/VersionCompareModal.test.tsx @@ -8,9 +8,20 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; const compareLokeeVersions = vi.fn(); +const planLokeeRevert = vi.fn(); +const executeLokeeRevert = vi.fn(); vi.mock('../../api/lokeeApi', () => ({ compareLokeeVersions: (...args: unknown[]) => compareLokeeVersions(...args), + planLokeeRevert: (...args: unknown[]) => planLokeeRevert(...args), + executeLokeeRevert: (...args: unknown[]) => executeLokeeRevert(...args), + LokeeRevertError: class extends Error { + constructor(message: string, public code: string) { + super(message); + } + }, })); +vi.mock('../../store/toastStore', () => ({ toast: vi.fn() })); +vi.mock('../../lib/sessionPasswords', () => ({ getSessionPassword: () => undefined })); import { VersionCompareModal } from './VersionCompareModal'; @@ -26,7 +37,11 @@ const VERSION = (number: number) => ({ changeCount: 2, }); -beforeEach(() => compareLokeeVersions.mockReset()); +beforeEach(() => { + compareLokeeVersions.mockReset(); + planLokeeRevert.mockReset(); + executeLokeeRevert.mockReset(); +}); describe('VersionCompareModal', () => { it('names the field that changed, with both values', async () => { @@ -124,3 +139,129 @@ describe('VersionCompareModal', () => { expect(onClose).toHaveBeenCalled(); }); }); + +describe('a revert only runs against the current database', () => { + /** + * The bug this pins: the diff came from `compareLokeeVersions(original, + * target)` while the plan and the execute call went to + * `planLokeeRevert(original)` — which reverses from the *live head*. With an + * older version on Target, the reader reviewed one script and Execute applied + * a different, usually larger one. + */ + const CHANGED = { + from: VERSION(10), + to: VERSION(13), + compare: { + summary: { added: 0, removed: 0, modified: 1, unchanged: 4 }, + tables: [ + { + tableName: 'CUSTOMERS', + objectType: 'TABLE', + status: 'MODIFIED', + columnDiffs: [ + { + name: 'name', + status: 'MODIFIED', + source: { type: 'varchar(100)', nullable: true }, + target: { type: 'varchar(150)', nullable: true }, + }, + ], + indexDiffs: [], + foreignKeyDiffs: [], + }, + ], + }, + }; + + const PLAN = { + fromVersion: VERSION(15), + toVersion: VERSION(10), + alreadyAtTarget: false, + reversal: { risk: 'safe', safeCount: 1, lossyCount: 0, blockedCount: 0, verdicts: [] }, + statements: ['ALTER TABLE customers ALTER COLUMN name TYPE varchar(100)'], + }; + + it('refuses when Target is an older version, and offers the one-click fix', async () => { + compareLokeeVersions.mockResolvedValue(CHANGED); + planLokeeRevert.mockResolvedValue(PLAN); + const onRetargetToLatest = vi.fn(); + + render( + undefined} + /> + ); + + await waitFor(() => + expect(screen.getByTestId('lokee-cmp-run-revert').textContent).toContain( + 'Target must be current' + ) + ); + const run = screen.getByTestId('lokee-cmp-run-revert') as HTMLButtonElement; + expect(run.disabled).toBe(true); + expect(run.title).toMatch(/not what would run/i); + + fireEvent.click(screen.getByTestId('lokee-cmp-use-current-target')); + expect(onRetargetToLatest).toHaveBeenCalled(); + expect(executeLokeeRevert).not.toHaveBeenCalled(); + }); + + it('allows the run when Target is the newest version', async () => { + compareLokeeVersions.mockResolvedValue(CHANGED); + planLokeeRevert.mockResolvedValue(PLAN); + + render( + undefined} + /> + ); + + // Falls through to the ordinary "tick something" guard — which is the + // point: the direction check is out of the way, not the last word. + await waitFor(() => + expect(screen.getByTestId('lokee-cmp-run-revert').textContent).toContain( + 'Tick objects to revert' + ) + ); + expect(screen.getByTestId('lokee-cmp-run-revert').textContent).not.toContain( + 'Target must be current' + ); + expect(screen.queryByTestId('lokee-cmp-use-current-target')).toBeNull(); + }); + + it('does not block when the caller supplies no latest version', async () => { + // The graph inspector opens this modal without the history bar's context. + compareLokeeVersions.mockResolvedValue(CHANGED); + planLokeeRevert.mockResolvedValue(PLAN); + + render( + undefined} + /> + ); + + await waitFor(() => + expect(screen.getByTestId('lokee-cmp-run-revert').textContent).toContain( + 'Tick objects to revert' + ) + ); + expect(screen.getByTestId('lokee-cmp-run-revert').textContent).not.toContain( + 'Target must be current' + ); + }); +}); diff --git a/apps/web/src/frontend/components/lokee-weave/VersionCompareModal.tsx b/apps/web/src/frontend/components/lokee-weave/VersionCompareModal.tsx index 54d398c4..49f40045 100644 --- a/apps/web/src/frontend/components/lokee-weave/VersionCompareModal.tsx +++ b/apps/web/src/frontend/components/lokee-weave/VersionCompareModal.tsx @@ -55,6 +55,14 @@ export interface VersionCompareModalProps { * reference's own parent, which is the "what did this version do?" reading. */ againstVersionId?: string; + /** + * Newest captured version. A revert always moves the *live* database, so it + * is only coherent when the Target side is that newest version — otherwise + * the diff on screen and the DDL that would run describe different pairs. + */ + latestVersionId?: string; + /** Put the Target side back on "Current database", so a revert is possible. */ + onRetargetToLatest?: () => void; onClose: () => void; } @@ -86,6 +94,8 @@ export function VersionCompareModal({ onReverted, versionId, againstVersionId, + latestVersionId, + onRetargetToLatest, onClose, }: VersionCompareModalProps): React.ReactElement { const [tab, setTab] = useState('DIFF'); @@ -212,6 +222,20 @@ export function VersionCompareModal({ why: planning ? 'Still planning…' : 'No plan yet.', }; } + /** + * A revert restores the Original version onto the *live* database. The diff + * above, though, is Original vs whatever Target says — so with an older + * version on Target the reader reviews one script and Execute applies a + * different, usually larger one. Refuse rather than reconcile: the rule is + * that a revert only ever runs against the newest version. + */ + if (latestVersionId && againstVersionId && againstVersionId !== latestVersionId) { + return { + code: 'target-not-latest', + label: 'Target must be current', + why: 'A revert restores the live database, so Target has to be “Current database”. The diff shown here compares two older versions and is not what would run.', + }; + } // Revert always moves the *live* database to whatever sits on the Original // side; the Target picker only chooses what the diff above is showing. So // putting the newest version on Original asks to revert to where you @@ -254,7 +278,16 @@ export function VersionCompareModal({ // selectedKeys and changed belong here: without them, ticking an object // left this memo stale and the button kept saying "Tick objects to revert" // after the user had ticked one. - }, [captureConnectionId, plan, planning, confirmLossy, selectedKeys, changed]); + }, [ + captureConnectionId, + plan, + planning, + confirmLossy, + selectedKeys, + changed, + latestVersionId, + againstVersionId, + ]); const needsLossyAck = blocked?.code === 'lossy'; const runRevert = useCallback(async () => { @@ -280,6 +313,15 @@ export function VersionCompareModal({ } catch (err) { const message = err instanceof LokeeRevertError ? err.message : err instanceof Error ? err.message : 'Revert failed'; + // Drift is not a failure the user caused: the schema moved under the + // plan, the snapshot caught it, and nothing was applied. Reload so the + // new version is on screen before they decide again. + if (err instanceof LokeeRevertError && err.code === 'schema_drifted') { + toast({ tone: 'warning', title: 'Schema changed — nothing applied', body: message }); + onReverted?.(); + onClose(); + return; + } toast({ tone: 'warning', title: 'Revert failed', body: message }); } finally { setRunning(false); @@ -485,6 +527,20 @@ export function VersionCompareModal({ size="compact" />
+ {/* A dead Execute button with the reason hidden in a + tooltip is a dead end; when the fix is one click, + offer the click. */} + {blocked?.code === 'target-not-latest' && onRetargetToLatest && ( + + )} {/* Risk travels with the button, not just with the tab that happens to show the statements. */} {plan && plan.statements.length > 0 && ( diff --git a/apps/web/src/frontend/lib/historyCompare.test.ts b/apps/web/src/frontend/lib/historyCompare.test.ts index b6cdd74f..87d4d973 100644 --- a/apps/web/src/frontend/lib/historyCompare.test.ts +++ b/apps/web/src/frontend/lib/historyCompare.test.ts @@ -108,3 +108,36 @@ describe('labels', () => { ).toBe('POSTGRES · localhost/foxdb · public (3 v)'); }); }); + +describe('compare-only labelling', () => { + // A revert restores the live database from the newest version. Two picks can + // be compared but never reverted, and both used to look exactly like every + // other option until Execute greyed out with the reason in a tooltip. + const v = { id: 'v7', number: 7 }; + + it('marks the newest version when it sits on the Original side', () => { + expect(historyVersionLabel(v, { compareOnly: 'is-current' })).toBe( + 'Version 7 — current, nothing to restore' + ); + }); + + it('marks an older version chosen as Target', () => { + expect(historyVersionLabel(v, { compareOnly: 'not-current' })).toBe('Version 7 — compare only'); + }); + + it('leaves an ordinary choice unmarked', () => { + expect(historyVersionLabel(v)).toBe('Version 7'); + }); + + it('keeps the current-database wording, which outranks the marker', () => { + expect(historyVersionLabel(v, { current: true, compareOnly: 'not-current' })).toBe( + 'Current database (Version 7)' + ); + }); + + it('keeps a custom version name in a marked label', () => { + expect( + historyVersionLabel({ id: 'v7', number: 7, name: 'before launch' }, { compareOnly: 'not-current' }) + ).toBe('v7 · before launch — compare only'); + }); +}); diff --git a/apps/web/src/frontend/lib/historyCompare.ts b/apps/web/src/frontend/lib/historyCompare.ts index 85ab570d..cb323990 100644 --- a/apps/web/src/frontend/lib/historyCompare.ts +++ b/apps/web/src/frontend/lib/historyCompare.ts @@ -71,11 +71,26 @@ export function swapHistoryCompare( export function historyVersionLabel( version: HistoryVersionOption, - opts?: { current?: boolean } + opts?: { + current?: boolean; + /** + * Say so when a choice can be compared but never reverted, instead of + * letting it look like every other option and dead-ending at Execute. + * + * A revert restores the live database, so it only runs from the newest + * version: picking the newest as Original restores where you already are, + * and picking an older Target means the diff on screen is not the DDL that + * would run. + */ + compareOnly?: 'is-current' | 'not-current'; + } ): string { const custom = version.name?.trim(); const base = custom ? `v${version.number} · ${custom}` : `Version ${version.number}`; - return opts?.current ? `Current database (${base})` : base; + if (opts?.current) return `Current database (${base})`; + if (opts?.compareOnly === 'is-current') return `${base} — current, nothing to restore`; + if (opts?.compareOnly === 'not-current') return `${base} — compare only`; + return base; } export function lokeeDatabaseLabel(database: { diff --git a/apps/web/src/shared/lokee-wire.ts b/apps/web/src/shared/lokee-wire.ts index 4596900b..df563bd9 100644 --- a/apps/web/src/shared/lokee-wire.ts +++ b/apps/web/src/shared/lokee-wire.ts @@ -226,6 +226,13 @@ export type LokeeRevertErrorCode = | 'blocked' | 'confirm_lossy' | 'connection_mismatch' + /** + * The pre-revert snapshot found the live schema had moved since the last + * capture. The plan the caller reviewed was computed against the old picture, + * so it is refused rather than applied — re-read the new diff and decide + * again. + */ + | 'schema_drifted' | 'failed'; /** From 1e382fe5e7f393f2cab732492cb79ecd9251dbdf Mon Sep 17 00:00:00 2001 From: huyplb Date: Tue, 18 Aug 2026 16:34:17 -0600 Subject: [PATCH 2/6] feat(history): say it in the reader's words, and stop showing filters that do nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A launch-readiness pass over the schema history module, reading it as someone seeing it for the first time. **One real bug, found by reading a card.** A deleted column came back from the graph DTO typed as `table` — `objectType: row.object_type ?? 'table'`, and the delta row carries no type. So a deleted column was drawn with a table's icon and colour, and slipped through the default table filter as a phantom table. The key already says what it is (`column:ORDERS.NOTE`), so the fallback now reads the kind from the key. **Names the database actually uses.** Children were labelled with their compare key — `ORDERS.NOTE` — which CLAUDE.md is explicit is an uppercased match key and never an identifier. Cards now read `NOTE` with `deleted from orders` beneath. **Filters that can change what you see.** The object-type list was the union of every dialect, so a SQLite user got MQT — a term only Db2 uses — beside Procedure and Function boxes that could never match. It now offers the types the history contains; a type the user ticked themselves stays. On the SQLite demo that is 8 checkboxes down to 2. **Words, not internals.** "reused" was the store's vocabulary for one object pointed at by many versions; a reader is asking what moved. Summary reads "12 unchanged", the legend "Unchanged", the edge key "Unchanged from previous version". "Content-addressed schema history (Lokee)" — an internal codename and an implementation detail — is now what the feature does. The header says Schema history everywhere. **A first run you can act on.** The empty state was a paragraph pointing at a button on another bar: the one screen where a newcomer has nothing to act on was the one screen with no action on it. It now holds the database picker and a "Take first snapshot" button. Also `[sqlite] /tmp/app.db.main` → `sqlite · /tmp/app.db · main`; the dotted schema suffix read as a file extension. Co-Authored-By: Claude Opus 5 --- apps/e2e/src/tests/schema-history.test.ts | 12 +- .../src/backend/modules/lokee-weave.module.ts | 19 +++- .../src/frontend/components/TopToolbar.tsx | 4 +- .../lokee-weave/LokeeObjectInspector.tsx | 2 +- .../components/lokee-weave/LokeeWeavePage.tsx | 29 +++-- .../lokee-weave/LokeeWeaveView.test.tsx | 20 +++- .../components/lokee-weave/LokeeWeaveView.tsx | 67 ++++++++++-- .../components/lokee-weave/buildGraph.test.ts | 103 ++++++++++++++++++ .../components/lokee-weave/buildGraph.ts | 37 +++++++ .../components/lokee-weave/graphTypes.ts | 22 ++++ .../frontend/components/lokee-weave/nodes.tsx | 24 +++- apps/web/src/frontend/lib/lokeeColors.ts | 2 +- 12 files changed, 301 insertions(+), 40 deletions(-) diff --git a/apps/e2e/src/tests/schema-history.test.ts b/apps/e2e/src/tests/schema-history.test.ts index 61c9233f..61ed004e 100644 --- a/apps/e2e/src/tests/schema-history.test.ts +++ b/apps/e2e/src/tests/schema-history.test.ts @@ -130,10 +130,16 @@ describe.skipIf(!ready)('Schema Sync · History (SQLite)', () => { expect(await driver.locator('[data-testid="lokee-rf-changes-only"]').isChecked()).toBe(true); }); - it('exposes view / procedure / function type filters', async () => { + it('offers the object types this schema actually has, and not the rest', async () => { + // The seed is a SQLite database with a table, an index, a view and a + // trigger. Procedure / Function / MQT filters could never match anything + // here, and MQT is a term only Db2 uses — a filter that cannot change what + // you see is noise in front of the ones that can. expect(await history.typeFilterVisible('view')).toBe(true); - expect(await history.typeFilterVisible('procedure')).toBe(true); - expect(await history.typeFilterVisible('function')).toBe(true); + expect(await history.typeFilterVisible('index')).toBe(true); + expect(await history.typeFilterVisible('trigger')).toBe(true); + expect(await history.typeFilterVisible('mqt')).toBe(false); + expect(await history.typeFilterVisible('procedure')).toBe(false); await history.enableType('view'); await driver.locator('[data-testid^="rf-object-"]').filter({ hasText: 'v_customers' }).first().waitFor({ timeout: 10_000, diff --git a/apps/web/src/backend/modules/lokee-weave.module.ts b/apps/web/src/backend/modules/lokee-weave.module.ts index 3398f402..044a3bd8 100644 --- a/apps/web/src/backend/modules/lokee-weave.module.ts +++ b/apps/web/src/backend/modules/lokee-weave.module.ts @@ -924,7 +924,11 @@ export class LokeeWeaveStore { versionId: version.id, objectKey: key, name: fallbackName(key), - objectType: (row.object_type as LokeeObjectType) ?? 'table', + // The key already carries the kind — `column:ORDERS.NOTE` is a + // column. Falling back to 'table' drew a deleted column with a + // table's icon and colour, and the card showed the raw compare key + // because the display split trusted that wrong type. + objectType: typeFromRowOrKey(row.object_type, key), objectHash: null, status: 'deleted', }); @@ -947,7 +951,7 @@ export class LokeeWeaveStore { versionId: version.id, objectKey: key, name: info?.name ?? fallbackName(key), - objectType: (info?.type as LokeeObjectType) ?? (row?.object_type as LokeeObjectType) ?? 'table', + objectType: (info?.type as LokeeObjectType) ?? typeFromRowOrKey(row?.object_type, key), objectHash: hash, status: row?.operation === 'ADD' ? 'added' : row?.operation === 'MODIFY' ? 'modified' : 'unchanged', @@ -1554,6 +1558,17 @@ function bodyFromRow(row: { body_json: string; shape_json?: string | null }): Re } /** `column:CUSTOMER.EMAIL` → `CUSTOMER.EMAIL`, for rows with no stored name. */ +/** + * The object's kind, preferring what the delta row recorded and falling back to + * the key rather than to a guess. `table` was the old fallback, which is only + * right for containers — every deleted column came back typed as a table. + */ +function typeFromRowOrKey(rowType: string | null | undefined, key: string): LokeeObjectType { + if (rowType) return rowType as LokeeObjectType; + const kind = objectKeyKind(key); + return (kind || 'table') as LokeeObjectType; +} + function fallbackName(objectKey: string): string { const colon = objectKey.indexOf(':'); return colon >= 0 ? objectKey.slice(colon + 1) : objectKey; diff --git a/apps/web/src/frontend/components/TopToolbar.tsx b/apps/web/src/frontend/components/TopToolbar.tsx index 8073316e..0a0c4b7b 100644 --- a/apps/web/src/frontend/components/TopToolbar.tsx +++ b/apps/web/src/frontend/components/TopToolbar.tsx @@ -111,7 +111,7 @@ export const TopToolbar: React.FC = () => { title: result.changed ? `Snapshot v${result.versionNumber}` : `No changes since v${result.versionNumber}`, body: result.changed ? `${result.changeCount} object change(s) · ${result.objectCount} objects` - : 'Target schema matches the last snapshot (hash pointer reused).', + : 'Target schema matches the last snapshot — nothing new to record.', }); setSyncPane('history'); } catch (err) { @@ -266,7 +266,7 @@ export const TopToolbar: React.FC = () => { type="button" data-testid="sync-pane-history-btn" onClick={() => setSyncPane('history')} - title="Content-addressed schema history (Lokee). Auto-snapshots on migrate." + title="Every version of this schema, and what changed between them. Snapshots automatically when you migrate." className={`rounded px-2.5 py-1 text-xs font-semibold transition ${ syncPane === 'history' ? 'bg-violet-700/80 text-violet-50 ring-1 ring-violet-400/40' diff --git a/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx b/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx index ff7ca19f..d14a7141 100644 --- a/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx +++ b/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx @@ -265,7 +265,7 @@ export function LokeeObjectInspector({ className={`mr-1 inline-block h-1.5 w-1.5 rounded-full align-middle ${ g.changed ? 'bg-amber-400' : 'bg-slate-700' }`} - title={g.changed ? 'Changed in this version' : 'Unchanged (reused)'} + title={g.changed ? 'Changed in this version' : 'Unchanged in this version'} aria-hidden /> v{g.versionNumber} diff --git a/apps/web/src/frontend/components/lokee-weave/LokeeWeavePage.tsx b/apps/web/src/frontend/components/lokee-weave/LokeeWeavePage.tsx index 19261638..c46c1e33 100644 --- a/apps/web/src/frontend/components/lokee-weave/LokeeWeavePage.tsx +++ b/apps/web/src/frontend/components/lokee-weave/LokeeWeavePage.tsx @@ -28,7 +28,7 @@ import { AlertTriangle, GripVertical, Lock, Unlock } from 'lucide-react'; import type { LokeeObjectType } from '@foxschema/sql'; import { OBJECT_STYLES, STATUS_STYLES, objectStyle, statusStyle } from '../../lib/lokeeColors'; import { LOKEE_NODE_TYPES } from './nodes'; -import { buildVersionGraph, carryMeasurements } from './buildGraph'; +import { buildVersionGraph, carryMeasurements, offeredObjectTypes } from './buildGraph'; import { useUiStore } from '../../store/uiStore'; import { VersionCompareModal } from './VersionCompareModal'; import { @@ -83,16 +83,6 @@ export interface LokeeWeavePageProps { embedded?: boolean; } -const FILTERABLE_TYPES: LokeeObjectType[] = [ - 'table', - 'view', - 'mqt', - 'index', - 'column', - 'trigger', - 'function', - 'procedure', -]; const STATUSES: GraphChangeStatus[] = ['added', 'modified', 'unchanged', 'deleted']; /** When a schema has this many distinct objects, default to tables-only. */ @@ -505,7 +495,7 @@ export const LokeeWeavePage: React.FC = ({ return (
- {FILTERABLE_TYPES.map((t) => ( + {offeredTypes.map((t) => (