From ac059b5149a702fe03d8a4669a7862a086d41c34 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 05:05:16 +0000 Subject: [PATCH 1/4] test(e2e): after Postgres migrate, functions have no Table growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate already snapshots Lokee before and after. Assert the History graph for demo_b: fn_order_total shows Source (no Table growth) and customers shows growth after the non-destructive demo_a → demo_b sync. Co-authored-by: huy.phan9 --- apps/e2e/src/pages/LokeeHistoryPage.ts | 23 +++++++++++++++++++++- apps/e2e/src/tests/dialects/shared-flow.ts | 15 ++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/e2e/src/pages/LokeeHistoryPage.ts b/apps/e2e/src/pages/LokeeHistoryPage.ts index 279011f1..601cb6af 100644 --- a/apps/e2e/src/pages/LokeeHistoryPage.ts +++ b/apps/e2e/src/pages/LokeeHistoryPage.ts @@ -77,15 +77,36 @@ export class LokeeHistoryPage { } async clickTableNode(tableName: string): Promise { + await this.clickObjectNamed(tableName); + } + + async clickObjectNamed(name: string): Promise { const node = this.page .locator('[data-testid^="rf-object-"]') - .filter({ hasText: tableName }) + .filter({ hasText: name }) .first(); 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() + .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(); + } + async inspectorText(): Promise { return (await this.page.locator('[data-testid="lokee-object-inspector"]').innerText()) ?? ''; } diff --git a/apps/e2e/src/tests/dialects/shared-flow.ts b/apps/e2e/src/tests/dialects/shared-flow.ts index 51c95f50..401d1d07 100644 --- a/apps/e2e/src/tests/dialects/shared-flow.ts +++ b/apps/e2e/src/tests/dialects/shared-flow.ts @@ -18,6 +18,7 @@ import { saveScreenshot } from '../../helpers/screenshot.js'; import { AppPage } from '../../pages/AppPage.js'; import { ConnectionModal } from '../../pages/ConnectionModal.js'; import { MigrationPage } from '../../pages/MigrationPage.js'; +import { LokeeHistoryPage } from '../../pages/LokeeHistoryPage.js'; import type { DbConfig } from '../../helpers/db-config.js'; export interface DialectFlowOptions { @@ -223,6 +224,20 @@ export function runDialectFlow( } else { 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. + 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); + } } await clickWhen(driver, '[data-testid="sync-pane-compare-btn"]'); }); From 86067dbbce75ca45952d4cfef488b973e888bff4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 05:08:41 +0000 Subject: [PATCH 2/4] test(e2e): wait for first-run signup wizard before expecting the toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skippable wizard can appear after /signup/state. Clicking Skip only in a short loop left Playwright on Loading… and timed out the Postgres flow. Co-authored-by: huy.phan9 --- apps/e2e/src/pages/AppPage.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/e2e/src/pages/AppPage.ts b/apps/e2e/src/pages/AppPage.ts index 7e076ece..41212692 100644 --- a/apps/e2e/src/pages/AppPage.ts +++ b/apps/e2e/src/pages/AppPage.ts @@ -10,13 +10,19 @@ export class AppPage { async open(): Promise { await this.page.goto(BASE_URL); + // First-run signup can appear after /signup/state (up to ~4s). Wait for + // either the workspace or the wizard so we don't miss Skip. + await this.page.waitForSelector( + '[data-testid="toolbar"], [data-testid="signup-wizard-skip"]', + { timeout: 30_000 } + ); + const skipSignup = this.page.locator('[data-testid="signup-wizard-skip"]'); + if (await skipSignup.isVisible().catch(() => false)) { + await skipSignup.click(); + await this.page.waitForSelector('[data-testid="toolbar"]', { timeout: 20_000 }); + } // One-time signup / onboarding wizards can cover the toolbar. for (let i = 0; i < 3; i++) { - const skipSignup = this.page.getByRole('button', { name: /skip for now/i }); - if (await skipSignup.isVisible().catch(() => false)) { - await skipSignup.click(); - await this.page.waitForTimeout(300); - } const skipOnboarding = this.page.getByRole('button', { name: /skip|continue|get started|finish|done/i }).first(); if ( !(await this.page.locator('[data-testid="toolbar"]').isVisible().catch(() => false)) && From bc6ee3aea3f2ee440e958262b00c873e08c10034 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 05:15:39 +0000 Subject: [PATCH 3/4] chore(e2e): local Postgres History one-shot (.env.example + reseed script) Cloud VMs cannot reach the developer laptop. Copy the docker-compose foxdb credentials into apps/e2e/.env.example and add a script that reseeds demo_a/demo_b then runs test:e2e:postgres against a local npm run dev. Co-authored-by: huy.phan9 --- apps/e2e/.env.example | 14 ++++++++++++++ scripts/e2e-postgres-history.sh | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 apps/e2e/.env.example create mode 100755 scripts/e2e-postgres-history.sh diff --git a/apps/e2e/.env.example b/apps/e2e/.env.example new file mode 100644 index 00000000..a4d5ceae --- /dev/null +++ b/apps/e2e/.env.example @@ -0,0 +1,14 @@ +# Copy to apps/e2e/.env (gitignored). Matches docker-compose.yml Postgres. +E2E_BASE_URL=http://127.0.0.1:5173 +E2E_POSTGRES_SOURCE_HOST=127.0.0.1 +E2E_POSTGRES_SOURCE_PORT=5432 +E2E_POSTGRES_SOURCE_DB=foxdb +E2E_POSTGRES_SOURCE_USER=foxuser +E2E_POSTGRES_SOURCE_PASS=foxpass +E2E_POSTGRES_SOURCE_SCHEMA=demo_a +E2E_POSTGRES_TARGET_HOST=127.0.0.1 +E2E_POSTGRES_TARGET_PORT=5432 +E2E_POSTGRES_TARGET_DB=foxdb +E2E_POSTGRES_TARGET_USER=foxuser +E2E_POSTGRES_TARGET_PASS=foxpass +E2E_POSTGRES_TARGET_SCHEMA=demo_b diff --git a/scripts/e2e-postgres-history.sh b/scripts/e2e-postgres-history.sh new file mode 100755 index 00000000..306ab220 --- /dev/null +++ b/scripts/e2e-postgres-history.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Reseed local Docker Postgres (demo_a / demo_b) and run compare → migrate e2e. +# Lokee snapshots the target before and after, so History on demo_b has v1+v2. +# +# Requires: foxschema-postgres running, `npm run dev` on :5173 / :3210. +# Usage: bash scripts/e2e-postgres-history.sh +set -euo pipefail +REPO="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO" + +if ! curl -sf http://127.0.0.1:3210/api/health >/dev/null; then + echo "API is not up on :3210. In another terminal: npm run dev" + exit 1 +fi +if ! curl -sf -o /dev/null http://127.0.0.1:5173/; then + echo "UI is not up on :5173. In another terminal: npm run dev" + exit 1 +fi + +if [ ! -f apps/e2e/.env ]; then + cp apps/e2e/.env.example apps/e2e/.env + echo "Wrote apps/e2e/.env from .env.example" +fi + +echo "▶ Reseed Postgres demo_a / demo_b (needed so migrate has a diff)" +bash "$REPO/scripts/seed/seed-all.sh" postgres + +echo "▶ Postgres compare → migrate → Lokee History" +npm run test:e2e:postgres + +echo +echo "Open http://127.0.0.1:5173 → Compare Schema → History" +echo "Picker: POSTGRES · foxdb · demo_b (target, not demo_a)" +echo "Expect 2 versions. fn_order_total = Source, no Table growth. customers = growth." From b809e0e26f58be95d231e81c9827ec5c68052c80 Mon Sep 17 00:00:00 2001 From: huyplb Date: Sat, 15 Aug 2026 15:03:38 -0600 Subject: [PATCH 4/4] refactor(lokee): one declaration per wire contract, and stop casting node data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven types were declared twice — once in lokee-weave.module.ts, once hand-copied into lokeeApi.ts — with nothing checking the copies against each other. Two had already drifted: `source` was widened from a four-value union to `string` on both LokeeVersion and LokeeHistoryEvent, and VersionGraphObject.schemaName was declared on a field no producer ever emits. The contract now lives once in apps/web/src/shared/lokee-wire.ts, the same place permissions.ts and server-beam.ts already serve. apps/web's tsconfig includes both src and packages, so producer, contract and consumer are checked in one pass and drift is a compile error. It does not go in @foxschema/sql: that package is published to npm and scoped to dialect knowledge, while these types carry metadata-DB primary keys, user ids and row counters. Types that genuinely are dialect knowledge — ObjectBlueprint, StoredWeaveObject, ReversalPlan — stay there and are now imported rather than hand-copied. Also: - graph()'s 25-line inline return type is now Promise, and the same shape it re-spelled a second time mid-method is VersionGraphObject[]. truncatedObjects moved onto the DTO, deleting two intersections, a redundant Boolean() coercion and a parallel useState. - inspectObject rebuilt the whole object map on every open — one spread per live object, 20,000 on a schema this module budgets for — purely to add a `key` the map key already held. The row literal carries it now. - The node-data interfaces became type aliases. `extends Record` was not required by React Flow (a type alias satisfies the constraint) and was defeating excess-property checks: a misspelled field in a node payload compiled clean. Verified it is now an error. NodeProps types `data`, so five casts are gone and onNodeClick narrows from node.type instead of checking and casting independently. One assertion remains where React Flow's NodeTypes genuinely erases the payload type — at registration, not inside every renderer. Co-Authored-By: Claude Opus 5 --- apps/e2e/src/pages/LokeeHistoryPage.ts | 57 +++--- apps/e2e/src/tests/dialects/postgres.test.ts | 16 +- apps/e2e/src/tests/dialects/shared-flow.ts | 42 ++-- apps/e2e/src/tests/schema-history.test.ts | 4 +- .../src/backend/modules/lokee-weave.module.ts | 177 +++++------------ apps/web/src/frontend/api/lokeeApi.ts | 138 +++---------- .../lokee-weave/LokeeObjectInspector.test.tsx | 34 ++++ .../lokee-weave/LokeeObjectInspector.tsx | 12 ++ .../components/lokee-weave/LokeeWeavePage.tsx | 11 +- .../lokee-weave/LokeeWeaveView.test.tsx | 1 + .../components/lokee-weave/LokeeWeaveView.tsx | 5 +- .../components/lokee-weave/buildGraph.test.ts | 30 ++- .../components/lokee-weave/buildGraph.ts | Bin 8560 -> 8728 bytes .../components/lokee-weave/graphTypes.ts | 76 ++++--- .../frontend/components/lokee-weave/nodes.tsx | 25 ++- apps/web/src/shared/lokee-wire.ts | 187 ++++++++++++++++++ 16 files changed, 466 insertions(+), 349 deletions(-) create mode 100644 apps/web/src/shared/lokee-wire.ts 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 d53186f2..72600b35 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, indexes and triggers', 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 c954ade2..4a571f38 100644 --- a/apps/web/src/backend/modules/lokee-weave.module.ts +++ b/apps/web/src/backend/modules/lokee-weave.module.ts @@ -51,6 +51,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'); @@ -68,7 +81,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[]; @@ -77,95 +99,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[]; -} - -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 { @@ -232,17 +171,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. */ @@ -764,31 +703,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: [], @@ -850,14 +767,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(); @@ -919,9 +829,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( @@ -970,6 +880,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), @@ -996,9 +907,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); @@ -1052,8 +965,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 2496e04f..fa47abeb 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,62 +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[]; - }>; -} - -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, @@ -190,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' | 'failed'; + readonly code: LokeeRevertErrorCode; readonly plan?: LokeeRevertPlan; constructor( message: string, - code: 'blocked' | 'confirm_lossy' | '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 6a4d93c9..f03b4cdc 100644 --- a/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.test.tsx +++ b/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.test.tsx @@ -271,6 +271,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 bf3377d4..c6c3967b 100644 --- a/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx +++ b/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx @@ -274,6 +274,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); @@ -344,6 +348,14 @@ export function LokeeObjectInspector({ return (