diff --git a/apps/e2e/src/pages/LokeeHistoryPage.ts b/apps/e2e/src/pages/LokeeHistoryPage.ts index 601cb6af..d87b3cd4 100644 --- a/apps/e2e/src/pages/LokeeHistoryPage.ts +++ b/apps/e2e/src/pages/LokeeHistoryPage.ts @@ -76,35 +76,48 @@ export class LokeeHistoryPage { await select.selectOption(value); } - async clickTableNode(tableName: string): Promise { - await this.clickObjectNamed(tableName); + /** Single owner of the object-node selector, shared by every accessor below. */ + private objectNode(name: string) { + return this.page.locator('[data-testid^="rf-object-"]').filter({ hasText: name }).first(); } async clickObjectNamed(name: string): Promise { - const node = this.page - .locator('[data-testid^="rf-object-"]') - .filter({ hasText: name }) - .first(); + const node = this.objectNode(name); await node.waitFor({ state: 'visible', timeout: 15_000 }); - await node.click(); - await waitFor(this.page, '[data-testid="lokee-object-inspector"]', 20_000); - } - - async objectNamedVisible(name: string): Promise { - return this.page - .locator('[data-testid^="rf-object-"]') - .filter({ hasText: name }) - .first() - .isVisible() + try { + await node.click({ timeout: 5_000 }); + } catch { + // React Flow clips its pane, so a node in a far-right column can sit + // outside the viewport. Fit the graph and click again for real — a + // dispatched synthetic click would bypass the actionability check, which + // is the one thing this test exists to prove a user can do. + await this.page.locator('.react-flow__controls-fitview').click({ timeout: 5_000 }); + await node.click({ timeout: 5_000 }); + } + await this.waitForInspectorLoaded(); + } + + /** + * The inspector shell renders immediately and fills in after an async fetch, + * so callers must not read it until the payload is in. `data-state` is set by + * the component; matching on it beats string-matching the loading copy. + */ + async waitForInspectorLoaded(timeoutMs = 20_000): Promise { + await this.page.waitForSelector('[data-testid="lokee-object-inspector"][data-state="ready"]', { + timeout: timeoutMs, + }); + } + + async objectNamedVisible(name: string, timeoutMs = 5_000): Promise { + return this.objectNode(name) + .waitFor({ state: 'visible', timeout: timeoutMs }) + .then(() => true) .catch(() => false); } - async inspectorHasGrowth(): Promise { - return this.page.locator('[data-testid="lokee-inspector-growth"]').isVisible(); - } - - async inspectorHasSource(): Promise { - return this.page.locator('[data-testid="lokee-inspector-source"]').isVisible(); + /** Sections render only once loaded, so callers must await the inspector first. */ + async inspectorHasSection(section: 'growth' | 'source' | 'columns' | 'indexes'): Promise { + return this.page.locator(`[data-testid="lokee-inspector-${section}"]`).isVisible(); } async inspectorText(): Promise { diff --git a/apps/e2e/src/tests/dialects/postgres.test.ts b/apps/e2e/src/tests/dialects/postgres.test.ts index ad885ca7..a79aff9e 100644 --- a/apps/e2e/src/tests/dialects/postgres.test.ts +++ b/apps/e2e/src/tests/dialects/postgres.test.ts @@ -8,6 +8,20 @@ describe.skipIf(!hasConfig(DIALECT))(`Compare flow: ${DIALECT}`, () => { runDialectFlow( DIALECT, () => getSourceConfig(DIALECT)!, - () => getTargetConfig(DIALECT)! + () => getTargetConfig(DIALECT)!, + { + // The demo_a→demo_b migrate adds fn_order_total and widens customers, so + // History has something to show. A routine must not report Table growth + // — that regression is the reason these assertions exist. + historyObjects: [ + { + name: 'fn_order_total', + expectSource: true, + expectGrowth: false, + expectTimeline: /v\d+\s*·\s*ADD/i, + }, + { name: 'customers', expectGrowth: true }, + ], + } ); }); diff --git a/apps/e2e/src/tests/dialects/shared-flow.ts b/apps/e2e/src/tests/dialects/shared-flow.ts index 401d1d07..d9e06f2b 100644 --- a/apps/e2e/src/tests/dialects/shared-flow.ts +++ b/apps/e2e/src/tests/dialects/shared-flow.ts @@ -21,12 +21,31 @@ import { MigrationPage } from '../../pages/MigrationPage.js'; import { LokeeHistoryPage } from '../../pages/LokeeHistoryPage.js'; import type { DbConfig } from '../../helpers/db-config.js'; +/** What the History inspector must show for one object after migrate. */ +export interface HistoryObjectExpectation { + /** Node label as rendered in the graph, e.g. `fn_order_total`. */ + name: string; + /** Routines have a Source section; tables do not. */ + expectSource?: boolean; + /** Table growth is meaningless for a routine and must not be shown. */ + expectGrowth: boolean; + /** Matched against the inspector's timeline text, e.g. /v\d+\s*·\s*ADD/i. */ + expectTimeline?: RegExp; +} + export interface DialectFlowOptions { /** * Skip execute/history steps. Used for dialects whose adapter is SELECT-only * in the SQL Editor / e2e path (e.g. SQLite) so compare still gets coverage. */ skipMigration?: boolean; + /** + * Objects to open in the History inspector after migrate, and what each must + * show. Expectations depend on the seed, so they live with the dialect that + * chooses it rather than as a dialect-name check inside this shared flow — + * another dialect running the same seed opts in with one line. + */ + historyObjects?: HistoryObjectExpectation[]; } export function runDialectFlow( @@ -225,18 +244,19 @@ export function runDialectFlow( await driver.locator('[data-testid^="rf-version-"]').first().waitFor({ timeout: 10_000 }); expect(await driver.locator('[data-testid^="rf-version-"]').count()).toBeGreaterThan(0); - // Postgres demo_a→demo_b migrate adds fn_order_total and widens customers. - // Functions must not show Table growth; tables must. + // Per-object inspector expectations, supplied by the dialect that knows + // its seed. Empty for dialects that have not opted in. const history = new LokeeHistoryPage(driver); - if (await history.objectNamedVisible('fn_order_total')) { - await history.clickObjectNamed('fn_order_total'); - expect(await history.inspectorHasSource()).toBe(true); - expect(await history.inspectorHasGrowth()).toBe(false); - expect(await history.inspectorText()).toMatch(/v\d+\s*·\s*ADD/i); - } - if (await history.objectNamedVisible('customers')) { - await history.clickObjectNamed('customers'); - expect(await history.inspectorHasGrowth()).toBe(true); + for (const expected of options.historyObjects ?? []) { + if (!(await history.objectNamedVisible(expected.name))) continue; + await history.clickObjectNamed(expected.name); + if (expected.expectSource !== undefined) { + expect(await history.inspectorHasSection('source')).toBe(expected.expectSource); + } + expect(await history.inspectorHasSection('growth')).toBe(expected.expectGrowth); + if (expected.expectTimeline) { + expect(await history.inspectorText()).toMatch(expected.expectTimeline); + } } } await clickWhen(driver, '[data-testid="sync-pane-compare-btn"]'); diff --git a/apps/e2e/src/tests/schema-history.test.ts b/apps/e2e/src/tests/schema-history.test.ts index 91664321..a82a9bb9 100644 --- a/apps/e2e/src/tests/schema-history.test.ts +++ b/apps/e2e/src/tests/schema-history.test.ts @@ -107,7 +107,7 @@ describe.skipIf(!ready)('Schema Sync · History (SQLite)', () => { }); it('opens a table blueprint with columns and a script diff', async () => { - await history.clickTableNode('customers'); + await history.clickObjectNamed('customers'); const text = await history.inspectorText(); expect(text).toMatch(/email/i); expect(await driver.locator('[data-testid="lokee-inspector-columns"]').isVisible()).toBe(true); @@ -151,7 +151,7 @@ describe.skipIf(!ready)('Schema Sync · History (SQLite)', () => { await expect .poll(async () => history.versionCount(), { timeout: 20_000 }) .toBeGreaterThan(before); - await history.clickTableNode('customers'); + await history.clickObjectNamed('customers'); const inspector = await history.inspectorText(); expect(inspector).toMatch(/phone/i); expect(await driver.locator('[data-testid="lokee-inspector-growth"]').isVisible()).toBe(true); diff --git a/apps/web/src/backend/modules/lokee-weave.module.ts b/apps/web/src/backend/modules/lokee-weave.module.ts index d26c2ca5..374b85c9 100644 --- a/apps/web/src/backend/modules/lokee-weave.module.ts +++ b/apps/web/src/backend/modules/lokee-weave.module.ts @@ -52,6 +52,19 @@ import { type TableSchema, } from '@foxschema/sql'; import { getStore } from '../database/store'; +import type { + CaptureResult, + CaptureSource, + ColumnMutation, + ContainerGrowthPoint, + LokeeDatabase, + ObjectHistoryEntry, + ObjectInspectResult, + RevertPlanWire, + VersionGraphDTO, + VersionGraphObject, + VersionSummary, +} from '../../shared/lokee-wire'; import type { MetadataStore, SqlParam } from '../database/stores/types'; const sha256 = (text: string): string => createHash('sha256').update(text, 'utf8').digest('hex'); @@ -69,7 +82,16 @@ const MAX_BIND_PARAMS = 900; /** Objects returned for one graph window, before the view's own node cap. */ const MAX_GRAPH_OBJECT_KEYS = 400; -export type CaptureSource = 'migrate' | 'manual' | 'scan' | 'revert'; +export type { + CaptureResult, + CaptureSource, + ColumnMutation, + ContainerGrowthPoint, + LokeeDatabase, + ObjectHistoryEntry, + ObjectInspectResult, + VersionSummary, +} from '../../shared/lokee-wire'; export interface CaptureInput extends DatabaseIdentityInput { tables: TableSchema[]; @@ -78,99 +100,12 @@ export interface CaptureInput extends DatabaseIdentityInput { migrationRunId?: string; } -export interface CaptureResult { - databaseId: string; - versionId: string; - versionNumber: number; - rootHash: string; - /** False when the schema was byte-for-byte what the index already held. */ - changed: boolean; - changeCount: number; - objectCount: number; -} - -export interface VersionedObject { - hash: string; - type: string; - name: string; - body: Record; - sourceText?: string | null; - lineCount?: number | null; - firstSeenAt?: string | null; -} - -export interface ObjectHistoryEntry { - versionId: string; - versionNumber: number; - createdAt: string; - source: CaptureSource; - operation: 'ADD' | 'MODIFY' | 'DELETE'; - hash?: string; - previousHash?: string; - body?: Record; - previousBody?: Record; - lineCount?: number | null; - previousLineCount?: number | null; - firstSeenAt?: string | null; - /** True when this hash was stored before this version — a pointer, not a copy. */ - reused: boolean; -} - -export interface ContainerGrowthPoint { - versionId: string; - versionNumber: number; - createdAt: string; - columns: number; - indexes: number; - foreignKeys: number; - triggers: number; - objects: number; -} - -export interface ColumnMutation { - objectKey: string; - columnName: string; - events: ObjectHistoryEntry[]; -} - -export interface ObjectInspectResult { - blueprint: ObjectBlueprint; - history: ObjectHistoryEntry[]; - growth: ContainerGrowthPoint[]; - /** Column ADD / MODIFY / DELETE across versions, when the focus is a table. */ - columnMutations: ColumnMutation[]; - /** CREATE script at this version (tables skip indexes). */ - script: string; - /** Adjacent older version's script, empty on v1. */ - previousScript: string; -} - -export interface RevertPlanResult { - fromVersion: VersionSummary; - toVersion: VersionSummary; - alreadyAtTarget: boolean; - reversal: ReversalPlan; +/** + * Backend-only: `steps` never crosses the wire (the routes strip it), so the + * published shape is `RevertPlanWire`. + */ +export interface RevertPlanResult extends RevertPlanWire { steps: MigrationStep[]; - statements: string[]; -} - -export interface VersionSummary { - id: string; - number: number; - rootHash: string; - createdAt: string; - lastObservedAt: string; - observationCount: number; - source: CaptureSource; - migrationRunId?: string; - authorUserId?: string; - /** Resolved email (or id) for filters / attribution. */ - author?: string; - /** Optional user-facing label; null means show "Version N". */ - name?: string; - description?: string; - objectCount: number; - changeCount: number; } interface VersionRow { @@ -237,17 +172,17 @@ export function chunkForBind(rows: readonly T[], paramsPerRow: number): T[][] return out; } -function toCanonical(key: string, object: VersionedObject): CanonicalObject { +function toCanonical(object: StoredWeaveObject): CanonicalObject { return { - key, + key: object.key, type: object.type as LokeeObjectType, body: object.body, sourceText: object.sourceText, }; } -function canonicalList(objects: Map): CanonicalObject[] { - return [...objects.entries()].map(([key, object]) => toCanonical(key, object)); +function canonicalList(objects: Map): CanonicalObject[] { + return [...objects.values()].map(toCanonical); } /** `LIKE` prefix for children of one owner. `!` is the ESCAPE character. */ @@ -788,31 +723,9 @@ export class LokeeWeaveStore { userId: string, databaseId: string, limit = 20 - ): Promise<{ - databaseId: string; - versions: Array<{ - id: string; - number: number; - createdAt: string; - rootHash: string; - author?: string; - name?: string; - description?: string; - }>; - objects: Array<{ - versionId: string; - objectKey: string; - name: string; - objectType: LokeeObjectType; - objectHash: string | null; - status: 'added' | 'modified' | 'unchanged' | 'deleted'; - }>; - totalVersions: number; - totalObjects: number; - truncatedObjects: boolean; - }> { + ): Promise { const store = await this.store(); - const empty = { + const empty: VersionGraphDTO = { databaseId, versions: [], objects: [], @@ -874,14 +787,7 @@ export class LokeeWeaveStore { } } - const objects: Array<{ - versionId: string; - objectKey: string; - name: string; - objectType: LokeeObjectType; - objectHash: string | null; - status: 'added' | 'modified' | 'unchanged' | 'deleted'; - }> = []; + const objects: VersionGraphObject[] = []; for (const version of versions) { const state = states.get(version.id) ?? new Map(); @@ -943,9 +849,9 @@ export class LokeeWeaveStore { userId: string, databaseId: string, versionId: string - ): Promise> { + ): Promise> { const store = await this.store(); - const out = new Map(); + const out = new Map(); if (!(await this.assertOwned(store, userId, databaseId))) return out; const target = await store.get( @@ -994,6 +900,7 @@ export class LokeeWeaveStore { // still exists and its identity is still known. } out.set(row.object_key, { + key: row.object_key, hash: row.hash, type: row.object_type, name: row.name ?? fallbackName(row.object_key), @@ -1020,9 +927,11 @@ export class LokeeWeaveStore { const store = await this.store(); if (!(await this.assertOwned(store, userId, databaseId))) return null; - const atVersion = await this.objectsAtVersion(userId, databaseId, versionId); - const stored = new Map(); - for (const [key, object] of atVersion) stored.set(key, { key, ...object }); + // Carries `key`, so it is already the shape the blueprint wants. Rebuilding + // the map to add a field the map key already holds cost one object spread + // per live object — 20,000 of them on a schema this module budgets for, + // every time the inspector opened. + const stored = await this.objectsAtVersion(userId, databaseId, versionId); const owner = objectKeyOwner(objectKey); const kind = objectKeyKind(objectKey); const blueprint = assembleBlueprint(objectKey, stored); @@ -1034,9 +943,9 @@ export class LokeeWeaveStore { const here = versions.findIndex((v) => v.id === versionId); const older = here >= 0 ? versions[here + 1] : undefined; if (older) { - const prevAt = await this.objectsAtVersion(userId, databaseId, older.id); - const prevStored = new Map(); - for (const [key, object] of prevAt) prevStored.set(key, { key, ...object }); + // Already the blueprint's shape — see the note on the current-version + // read above; re-pairing it here would spread `key` over itself. + const prevStored = await this.objectsAtVersion(userId, databaseId, older.id); previousScript = renderLokeeObjectScript(assembleBlueprint(objectKey, prevStored)); } return { @@ -1089,8 +998,8 @@ export class LokeeWeaveStore { if (cur && tgt && cur.hash === tgt.hash) continue; entries.push({ key, - current: cur ? toCanonical(key, cur) : undefined, - target: tgt ? toCanonical(key, tgt) : undefined, + current: cur ? toCanonical(cur) : undefined, + target: tgt ? toCanonical(tgt) : undefined, }); } diff --git a/apps/web/src/frontend/api/lokeeApi.ts b/apps/web/src/frontend/api/lokeeApi.ts index 8970a483..8c4914ba 100644 --- a/apps/web/src/frontend/api/lokeeApi.ts +++ b/apps/web/src/frontend/api/lokeeApi.ts @@ -9,48 +9,32 @@ * resolved and decrypted server-side, and an ad-hoc one carries only what the * user typed for this session. */ +import type { ObjectBlueprint, StoredWeaveObject } from '@foxschema/sql'; import type { ConnectionRef } from './schemaApi'; import type { VersionGraphDTO } from '../components/lokee-weave/graphTypes'; +import type { + CaptureRequestSource, + CaptureResult, + LokeeDatabase, + ColumnMutation, + ContainerGrowthPoint, + LokeeRevertErrorCode, + ObjectHistoryEntry, + ObjectInspectResult, + RevertPlanWire, + VersionSummary, +} from '../../shared/lokee-wire'; import { getApiBase, parseJsonBody, parseJsonResponse } from './apiBase'; -export interface LokeeDatabase { - id: string; - dialect: string; - host?: string; - database?: string; - schema?: string; - versionCount: number; - lastSeenAt: string; -} - -export interface LokeeVersion { - id: string; - number: number; - rootHash: string; - createdAt: string; - lastObservedAt: string; - observationCount: number; - source: string; - migrationRunId?: string; - authorUserId?: string; - /** Resolved email for attribution / filters. */ - author?: string; - /** Optional display name; omit to show "Version N". */ - name?: string; - description?: string; - objectCount: number; - changeCount: number; -} - -export interface CaptureResult { - databaseId: string; - versionId: string; - versionNumber: number; - rootHash: string; - changed: boolean; - changeCount: number; - objectCount: number; -} +// These were hand-copied from the backend until the shared contract landed; +// two had already drifted (`source` widened to `string`). Aliases keep the +// existing call sites while the declaration lives in one place. +export type { CaptureResult, LokeeDatabase } from '../../shared/lokee-wire'; +export type LokeeVersion = VersionSummary; +export type LokeeHistoryEvent = ObjectHistoryEntry; +export type LokeeStoredObject = StoredWeaveObject; +export type LokeeInspectResult = ObjectInspectResult; +export type LokeeRevertPlan = RevertPlanWire; /** * Capture the current schema of a database. @@ -93,12 +77,12 @@ export async function listLokeeVersions( export async function loadVersionGraph( databaseId: string, limit = 20 -): Promise { +): Promise { const res = await fetch( `${getApiBase()}/lokee/databases/${encodeURIComponent(databaseId)}/graph?limit=${limit}`, { credentials: 'include' } ); - return parseJsonResponse(res); + return parseJsonResponse(res); } /** Update the user-facing name and/or description on a version. */ @@ -120,64 +104,6 @@ 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; - container: LokeeStoredObject | null; - object: LokeeStoredObject | null; - columns: LokeeStoredObject[]; - indexes: LokeeStoredObject[]; - foreignKeys: LokeeStoredObject[]; - triggers: LokeeStoredObject[]; - primaryKey: LokeeStoredObject | null; - }; - history: LokeeHistoryEvent[]; - growth: Array<{ - versionId: string; - versionNumber: number; - createdAt: string; - columns: number; - indexes: number; - foreignKeys: number; - triggers: number; - objects: number; - }>; - columnMutations: Array<{ - objectKey: string; - columnName: string; - events: LokeeHistoryEvent[]; - }>; - script?: string; - previousScript?: string; -} - -export interface LokeeStoredObject { - key: string; - type: string; - name: string; - hash: string; - body: Record; - sourceText?: string | null; - lineCount?: number | null; - firstSeenAt?: string | null; -} - /** Blueprint + change timeline for one object at one version. */ export async function inspectLokeeObject( databaseId: string, @@ -192,26 +118,12 @@ export async function inspectLokeeObject( return parseJsonResponse(res); } -export interface LokeeRevertPlan { - fromVersion: LokeeVersion; - toVersion: LokeeVersion; - alreadyAtTarget: boolean; - reversal: { - verdicts: Array<{ key: string; risk: 'safe' | 'lossy' | 'blocked'; summary: string; dataLoss?: string }>; - risk: 'safe' | 'lossy' | 'blocked'; - safeCount: number; - lossyCount: number; - blockedCount: number; - }; - statements: string[]; -} - export class LokeeRevertError extends Error { - readonly code: 'blocked' | 'confirm_lossy' | 'connection_mismatch' | 'failed'; + readonly code: LokeeRevertErrorCode; readonly plan?: LokeeRevertPlan; constructor( message: string, - code: 'blocked' | 'confirm_lossy' | 'connection_mismatch' | 'failed', + code: LokeeRevertErrorCode, plan?: LokeeRevertPlan ) { super(message); 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 6428597a..b9c2eff7 100644 --- a/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.test.tsx +++ b/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.test.tsx @@ -294,6 +294,40 @@ describe('LokeeObjectInspector', () => { expect(screen.getByTestId('lokee-inspector-history').textContent).toMatch(/v1 · ADD/); }); + it('declares its load state so callers need not read the loading copy', async () => { + // The e2e page object waits on [data-state="ready"] before reading any + // section. Losing this attribute would not fail a render test — it would + // hang the browser suite on a timeout — so assert the contract here. + let resolve!: (value: unknown) => void; + inspectLokeeObject.mockReturnValue(new Promise((r) => { resolve = r; })); + const { container } = render( + undefined} /> + ); + const aside = container.querySelector('[data-testid="lokee-object-inspector"]')!; + expect(aside.getAttribute('data-state')).toBe('loading'); + + resolve({ + blueprint: { + focusKey: 'table:CUSTOMERS', + container: { key: 'table:CUSTOMERS', type: 'table', name: 'customers', hash: 'h1', body: {} }, + object: { key: 'table:CUSTOMERS', type: 'table', name: 'customers', hash: 'h1', body: {} }, + columns: [], + indexes: [], + foreignKeys: [], + triggers: [], + primaryKey: null, + }, + history: [], + growth: [], + columnMutations: [], + }); + + await waitFor(() => expect(aside.getAttribute('data-state')).toBe('ready')); + // Payload-derived, so it changes only once *this* object's fetch lands — + // `selected.name` updates synchronously on click and proves nothing. + expect(aside.getAttribute('data-object-key')).toBe('table:CUSTOMERS'); + }); + it('plans a revert when a prior version is selected', async () => { inspectLokeeObject.mockResolvedValue({ blueprint: { diff --git a/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx b/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx index ce51e805..ec32a51f 100644 --- a/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx +++ b/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx @@ -347,6 +347,10 @@ export function LokeeObjectInspector({ setLoading(true); setError(null); setRevert(null); + // Drop the previous object's payload: this component is not remounted when + // the selection changes, so keeping it would render the old blueprint, + // source, and growth under the new object's name until the fetch lands. + setData(null); void inspectLokeeObject(databaseId, selected.versionId, selected.objectKey) .then((result) => { if (!cancelled) setData(result); @@ -419,6 +423,14 @@ export function LokeeObjectInspector({ return (