diff --git a/apps/web/src/backend/api/deployment.test.ts b/apps/web/src/backend/api/deployment.test.ts new file mode 100644 index 00000000..6151f1b4 --- /dev/null +++ b/apps/web/src/backend/api/deployment.test.ts @@ -0,0 +1,32 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { isLocalSingleUser } from './deployment'; + +const original = process.env.LOCAL_SINGLE_USER; + +afterEach(() => { + if (original === undefined) delete process.env.LOCAL_SINGLE_USER; + else process.env.LOCAL_SINGLE_USER = original; +}); + +describe('isLocalSingleUser', () => { + it('defaults to true and is read per call, not captured at import', () => { + delete process.env.LOCAL_SINGLE_USER; + expect(isLocalSingleUser()).toBe(true); + // A module-load snapshot would keep returning true here, and the routes + // that gate on it would stay open on a multi-user deployment. + process.env.LOCAL_SINGLE_USER = 'false'; + expect(isLocalSingleUser()).toBe(false); + process.env.LOCAL_SINGLE_USER = 'true'; + expect(isLocalSingleUser()).toBe(true); + }); + + it('only the exact string "false" opts out', () => { + process.env.LOCAL_SINGLE_USER = '0'; + expect(isLocalSingleUser()).toBe(true); + }); +}); diff --git a/apps/web/src/backend/api/deployment.ts b/apps/web/src/backend/api/deployment.ts new file mode 100644 index 00000000..db002831 --- /dev/null +++ b/apps/web/src/backend/api/deployment.ts @@ -0,0 +1,21 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Which deployment shape this process is running as. + * + * The answer decides more than which auth guard is installed: a handful of + * routes are only safe when the caller is the person sitting at the machine + * (probing an arbitrary metadata-DB URL, installing a driver, self-updating). + * Those checks used to be described in comments while the code did nothing, so + * the predicate lives here where a route can actually call it. + * + * Read per call rather than captured at import: tests flip the variable, and a + * module-load snapshot silently ignores them. + */ + +/** Default is single-user (no login). `LOCAL_SINGLE_USER=false` opts out. */ +export function isLocalSingleUser(): boolean { + return process.env.LOCAL_SINGLE_USER !== 'false'; +} diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index f859c3e8..46f51962 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -54,6 +54,7 @@ import { createMetadataStore } from '../database/stores/registry'; import { keySchemeInfo } from '../cores/crypto'; import type { AuthedRequest } from './auth.routes'; import { denyUnless, requirePermissions } from './rbac.middleware'; +import { isLocalSingleUser } from './deployment'; import { CATEGORY_PERMISSION, DATAGRID_ACTION_PERMISSION, isDatagridAction, permissionSatisfied, type Permission } from '../../shared/permissions'; import { toHttpError, type ActorContext } from '../features/actor'; import { makeConnectionResolver, type ConnectionRef } from '../features/connections/resolve'; @@ -202,6 +203,18 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt // Restricted to the local/community edition — on multi-user web the metadata // DB is ops-managed, and a connection probe would be an SSRF vector. router.post('/db/test', async (req: Request, res: Response) => { + // The restriction above was documented but never implemented. On a + // multi-user deployment this handler dials any host:port the caller names + // and reports, through the error text, whether something answered — an + // SSRF and internal port-scan primitive, on a route that carries no + // permission check. Local single-user is the only place it belongs. + if (!isLocalSingleUser()) { + res.status(403).json({ + ok: false, + error: 'Changing the metadata database is not available on this deployment.', + }); + return; + } const { engine, url, path } = req.body as { engine?: string; url?: string; path?: string }; if (!engine || !SUPPORTED_ENGINES.includes(engine as DbEngine)) { res.status(400).json({ ok: false, error: `Unsupported engine. Supported: ${SUPPORTED_ENGINES.join(', ')}.` }); @@ -997,6 +1010,28 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt res.json(result); }); + // Version-to-version diff, served from the object store — no connection to + // the compared database is needed or opened. + router.get('/lokee/databases/:id/compare', async (req: Request, res: Response) => { + const versionId = String(req.query.versionId ?? '').trim(); + if (!versionId) { + res.status(400).json({ error: 'versionId is required' }); + return; + } + const against = String(req.query.againstVersionId ?? '').trim(); + const result = await lokeeWeave.diffVersions( + (req as AuthedRequest).userId!, + String(req.params.id), + versionId, + against || undefined + ); + if (!result) { + res.status(404).json({ error: 'Version not found' }); + return; + } + res.json(result); + }); + router.get( '/lokee/databases/:id/revert/plan', requirePermissions('schema.browse'), @@ -1006,10 +1041,24 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt res.status(400).json({ error: 'toVersionId is required' }); return; } + // Optional selective revert: `?objectKeys=a&objectKeys=b`, or omitted for + // the whole schema. + // Absent means "whole schema"; present-but-empty means "nothing", and + // those must stay distinguishable all the way down. + const objectKeys = + req.query.objectKeys === undefined + ? undefined + : ([] as string[]) + .concat(req.query.objectKeys as string | string[]) + .map((k) => String(k).trim()) + .filter(Boolean); const plan = await lokeeWeave.planRevert( (req as AuthedRequest).userId!, String(req.params.id), - toVersionId + toVersionId, + undefined, + undefined, + objectKeys ); if (!plan) { res.status(404).json({ error: 'Version not found' }); @@ -1025,7 +1074,12 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt lokeeCaptureLimiter, requirePermissions('schema.migrate'), async (req: Request, res: Response) => { - const body = req.body as ConnectionRef & { toVersionId?: string; confirmLossy?: boolean }; + const body = req.body as ConnectionRef & { + toVersionId?: string; + confirmLossy?: boolean; + /** Revert only these objects; omit for the whole schema. */ + objectKeys?: string[]; + }; const toVersionId = String(body.toVersionId ?? '').trim(); if (!toVersionId) { res.status(400).json({ error: 'toVersionId is required' }); @@ -1066,7 +1120,17 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt return; } - const plan = await lokeeWeave.planRevert(userId, databaseId, toVersionId, dialect, schema); + const objectKeys = Array.isArray(body.objectKeys) + ? body.objectKeys.map((k) => String(k).trim()).filter(Boolean) + : undefined; + const plan = await lokeeWeave.planRevert( + userId, + databaseId, + toVersionId, + dialect, + schema, + objectKeys + ); if (!plan) { res.status(404).json({ error: 'Version not found' }); return; diff --git a/apps/web/src/backend/database/schema.ts b/apps/web/src/backend/database/schema.ts index 77ca236d..502421cc 100644 --- a/apps/web/src/backend/database/schema.ts +++ b/apps/web/src/backend/database/schema.ts @@ -344,6 +344,35 @@ const MIGRATIONS: Migration[] = [ ]; }, }, + { + id: 14, + name: 'lokee_shape_dedup', + statements: (d) => { + const t = types(d); + return [ + // Reusable half of an object body. An object body carries its own name + // and table, so `int not null` in forty tables was forty near-identical + // rows; the declaration is now stored once and pointed at. + // + // The object hash is unchanged — it is still computed over the whole + // body. This is a storage layout, not a change of identity, or every + // recorded version would need rehashing. + `CREATE TABLE IF NOT EXISTS lokee_shapes ( + shape_hash ${t.id} PRIMARY KEY, + shape_json ${t.big} NOT NULL, + created_at ${t.ts} NOT NULL + )`, + // Null for rows written before this migration and for any body that + // does not round-trip; those keep their whole body in body_json. + `ALTER TABLE lokee_objects ADD COLUMN shape_hash ${t.id}`, + // The history read path filters children by key prefix (LIKE + // 'column:OWNER.%'), which scans without this. + `CREATE INDEX idx_lokee_objects_key ON lokee_objects(object_key)`, + // Graph reconstruction walks deltas newest-first per version. + `CREATE INDEX idx_lokee_version_objects_hash ON lokee_version_objects(object_hash)`, + ]; + }, + }, ]; const SIGNUP_WIZARD_SHOWN_KEY = 'signup.wizard_shown'; 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 9e88dafe..7385cf35 100644 --- a/apps/web/src/backend/modules/lokee-weave.module.test.ts +++ b/apps/web/src/backend/modules/lokee-weave.module.test.ts @@ -500,6 +500,36 @@ describe('inspectObject', () => { expect(inspect?.growth[0]?.versionId).toBe(v1.versionId); }); + it('hands back a TableDiff scoped to this object, in the migrate direction', async () => { + // The inspector renders Compare Schema's own blueprint tables, so it needs + // Compare's own shape. Direction matches `diffVersions`: source is the newer + // state, so a column this version added reads ADDED, not REMOVED. + const { weave } = await freshStore(); + await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER, table('orders', [['id', 'integer', false]])], source: 'manual' }); + const widened = table('customer', [ + ['id', 'integer', false], + ['email', 'varchar(255)'], + ['phone', 'varchar(20)'], + ]); + const v2 = await weave.capture(USER, { + ...IDENTITY, + tables: [widened, table('orders', [['id', 'integer', false]])], + source: 'migrate', + }); + + const inspect = await weave.inspectObject(USER, v2.databaseId, v2.versionId, 'table:CUSTOMER'); + const diff = inspect?.diff; + expect(diff?.tableName).toBe('CUSTOMER'); + expect(diff?.columnDiffs.find((c) => c.name.toUpperCase() === 'PHONE')?.status).toBe('ADDED'); + const email = diff?.columnDiffs.find((c) => c.name.toUpperCase() === 'EMAIL'); + expect(email?.status).toBe('MODIFIED'); + expect(email?.source?.type).toBe('varchar(255)'); + expect(email?.target?.type).toBe('varchar(100)'); + // Scoped to the owner: the untouched sibling table is not compared, which is + // what keeps this cheap on a schema with thousands of objects. + expect(diff?.columnDiffs.every((c) => c.name.toUpperCase() !== 'ORDERS')).toBe(true); + }); + it('records procedure source lines without hashing whitespace', async () => { const { weave } = await freshStore(); const proc = { @@ -667,3 +697,344 @@ describe('reconstructStates', () => { expect(states.get('v2')).not.toBe(states.get('v1')); }); }); + +describe('shape dedup', () => { + it('stores one shape row for columns declared the same way', async () => { + // The optimisation: `integer not null` in three tables is one shape row. + const { weave, meta } = await freshStore(); + const same: [string, string, boolean?][] = [['id', 'integer', false]]; + await weave.capture(USER, { + ...IDENTITY, + tables: [table('a', same), table('b', same), table('c', same)], + source: 'manual', + }); + + const cols = await meta.get<{ n: number }>( + "SELECT COUNT(*) AS n FROM lokee_objects WHERE object_type = 'column'" + ); + const shapes = await meta.get<{ n: number }>( + "SELECT COUNT(DISTINCT shape_hash) AS n FROM lokee_objects WHERE object_type = 'column'" + ); + expect(Number(cols?.n)).toBe(3); + expect(Number(shapes?.n)).toBe(1); + }); + + it('rebuilds the exact body a read expects', async () => { + // The invariant the object hash rests on: what goes in comes back out. If + // this drifts, every stored hash stops matching its reconstructed body and + // the history becomes unverifiable. + const { weave } = await freshStore(); + const v1 = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' }); + const at = await weave.objectsAtVersion(USER, v1.databaseId, v1.versionId); + const email = at.get('column:CUSTOMER.EMAIL'); + expect(email?.body).toEqual({ + name: 'email', + table: 'customer', + dataType: 'varchar(100)', + nullable: true, + default: null, + identity: false, + identityGeneration: null, + collation: null, + }); + }); + + it('keeps distinct declarations apart', async () => { + const { weave, meta } = await freshStore(); + await weave.capture(USER, { + ...IDENTITY, + tables: [ + table('a', [['id', 'integer', false]]), + table('b', [['id', 'bigint', false]]), + ], + source: 'manual', + }); + const shapes = await meta.get<{ n: number }>( + "SELECT COUNT(DISTINCT shape_hash) AS n FROM lokee_objects WHERE object_type = 'column'" + ); + expect(Number(shapes?.n)).toBe(2); + }); + + it('does not change the root hash — this is storage, not identity', async () => { + // Two databases holding the same schema must still agree on the root hash + // after the split, or the "capture twice, get the same hash" invariant that + // the whole feature rests on is broken. + const { weave } = await freshStore(); + const a = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' }); + const b = await weave.capture(USER, { + ...IDENTITY, + database: 'other', + tables: [CUSTOMER], + source: 'manual', + }); + expect(a.rootHash).toBe(b.rootHash); + }); +}); + +describe('object roadmap (container growth)', () => { + it('covers the whole history, including the version the table was created in', async () => { + // Capped at the last 20 versions, a roadmap hides the creation event — + // which is the first thing a reader looks for. + const { weave } = await freshStore(); + const first = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' }); + + // Grow the table one column at a time, well past the old 20-version window. + for (let i = 0; i < 22; i++) { + const cols: Array<[string, string, boolean?]> = [ + ['id', 'integer', false], + ['email', 'varchar(100)'], + ...Array.from({ length: i + 1 }, (_, n) => [`extra_${n}`, 'text'] as [string, string]), + ]; + await weave.capture(USER, { ...IDENTITY, tables: [table('customer', cols)], source: 'migrate' }); + } + + const result = await weave.inspectObject(USER, first.databaseId, first.versionId, 'table:CUSTOMER'); + const growth = result!.growth; + expect(growth.length).toBeGreaterThan(20); + // Oldest point first, and it is the creation. + expect(growth[0]!.versionNumber).toBe(1); + expect(growth[0]!.columns).toBe(2); + // Monotonic growth, ending at 2 + 22 columns. + expect(growth.at(-1)!.columns).toBe(24); + }); + + it('marks only the versions where the container actually moved', async () => { + const { weave } = await freshStore(); + const v1 = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' }); + // A version that changes a *different* table must not mark CUSTOMER. + await weave.capture(USER, { + ...IDENTITY, + tables: [CUSTOMER, table('unrelated', [['id', 'integer', false]])], + source: 'migrate', + }); + + const result = await weave.inspectObject(USER, v1.databaseId, v1.versionId, 'table:CUSTOMER'); + const marks = result!.growth.map((g) => g.changed); + expect(marks[0]).toBe(true); // created here + expect(marks[1]).toBe(false); // untouched by the second migrate + }); +}); + +describe('selective revert', () => { + /** v1 → v2 widens customer.email and adds an unrelated table. */ + async function twoChanges() { + const { weave, meta } = await freshStore(); + const v1 = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' }); + const v2 = await weave.capture(USER, { + ...IDENTITY, + tables: [ + table('customer', [ + ['id', 'integer', false], + ['email', 'varchar(255)'], + ]), + table('orders', [['id', 'integer', false]]), + ], + source: 'migrate', + }); + return { weave, meta, v1, v2, databaseId: v1.databaseId }; + } + + it('reverts the whole schema when no objects are named', async () => { + const { weave, databaseId, v1 } = await twoChanges(); + const plan = await weave.planRevert(USER, databaseId, v1.versionId); + const keys = plan!.reversal.verdicts.map((v) => v.key).sort(); + // Both the widened column and the added table are in scope. + expect(keys).toContain('column:CUSTOMER.EMAIL'); + expect(keys).toContain('table:ORDERS'); + }); + + it('touches only what was selected', async () => { + const { weave, databaseId, v1 } = await twoChanges(); + const plan = await weave.planRevert(USER, databaseId, v1.versionId, undefined, undefined, [ + 'column:CUSTOMER.EMAIL', + ]); + const keys = plan!.reversal.verdicts.map((v) => v.key); + expect(keys).toEqual(['column:CUSTOMER.EMAIL']); + // The unrelated table stays exactly where it is. + expect(keys).not.toContain('table:ORDERS'); + }); + + it('pulls a container\'s children in with it', async () => { + // Reverting `table:ORDERS` without its columns would apply half a change + // and leave the table in a state no version ever held. + const { weave, databaseId, v1 } = await twoChanges(); + const plan = await weave.planRevert(USER, databaseId, v1.versionId, undefined, undefined, [ + 'table:ORDERS', + ]); + const keys = plan!.reversal.verdicts.map((v) => v.key).sort(); + expect(keys).toEqual(['column:ORDERS.ID', 'table:ORDERS']); + }); + + it('keeps the risk classification on the narrowed set', async () => { + // Narrowing must not quietly downgrade a lossy revert to safe — the + // confirmation depends on this verdict. + const { weave, databaseId, v1 } = await twoChanges(); + const plan = await weave.planRevert(USER, databaseId, v1.versionId, undefined, undefined, [ + 'column:CUSTOMER.EMAIL', + ]); + const verdict = plan!.reversal.verdicts[0]!; + // varchar(255) → varchar(100) truncates, so this is lossy, not safe. + expect(verdict.risk).toBe('lossy'); + expect(plan!.reversal.risk).toBe('lossy'); + }); + + it('treats an empty selection as nothing, not as everything', async () => { + // A UI with no boxes ticked sends `[]`. If that were read as "no filter", + // clicking Revert with nothing selected would rewrite the whole database. + const { weave, databaseId, v1 } = await twoChanges(); + const none = await weave.planRevert(USER, databaseId, v1.versionId, undefined, undefined, []); + expect(none!.reversal.verdicts).toEqual([]); + expect(none!.statements).toEqual([]); + expect(none!.alreadyAtTarget).toBe(true); + + // Omitting the argument entirely still means the whole schema. + const all = await weave.planRevert(USER, databaseId, v1.versionId); + expect(all!.reversal.verdicts.length).toBeGreaterThan(1); + }); +}); + +describe('version compare (shared Compare engine)', () => { + it('reads forward: what the newer version added', async () => { + // Direction is the trap. CompareModule answers "what must TARGET change to + // match SOURCE", so feeding (older, newer) reports every addition as a + // removal. This caught exactly that inversion against real history. + const { weave } = await freshStore(); + const v1 = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' }); + const v2 = await weave.capture(USER, { + ...IDENTITY, + tables: [ + table('customer', [ + ['id', 'integer', false], + ['email', 'varchar(255)'], + ['phone', 'text'], + ]), + ], + source: 'migrate', + }); + + const result = await weave.diffVersions(USER, v1.databaseId, v2.versionId); + expect(result!.from!.number).toBe(1); + expect(result!.to.number).toBe(2); + + const customer = result!.compare.tables.find((t) => t.tableName.toUpperCase() === 'CUSTOMER'); + const byName = Object.fromEntries( + customer!.columnDiffs.map((c) => [c.name.toUpperCase(), c.status]) + ); + // phone appeared in v2 → ADDED, not REMOVED. + expect(byName.PHONE).toBe('ADDED'); + expect(byName.EMAIL).toBe('MODIFIED'); + expect(result!.compare.summary.removed).toBe(0); + }); + + it('reports a drop as removed', async () => { + const { weave } = await freshStore(); + const v1 = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' }); + const v2 = await weave.capture(USER, { + ...IDENTITY, + tables: [table('customer', [['id', 'integer', false]])], + source: 'migrate', + }); + const result = await weave.diffVersions(USER, v1.databaseId, v2.versionId); + const customer = result!.compare.tables.find((t) => t.tableName.toUpperCase() === 'CUSTOMER'); + const email = customer!.columnDiffs.find((c) => c.name.toUpperCase() === 'EMAIL'); + expect(email!.status).toBe('REMOVED'); + }); + + it('compares against the parent by default, and any version on request', async () => { + const { weave } = await freshStore(); + const v1 = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' }); + await weave.capture(USER, { + ...IDENTITY, + tables: [table('customer', [['id', 'integer', false], ['email', 'varchar(255)']])], + source: 'migrate', + }); + const v3 = await weave.capture(USER, { + ...IDENTITY, + tables: [ + table('customer', [['id', 'integer', false], ['email', 'varchar(255)'], ['phone', 'text']]), + ], + source: 'migrate', + }); + + const adjacent = await weave.diffVersions(USER, v1.databaseId, v3.versionId); + expect(adjacent!.from!.number).toBe(2); + + const spanning = await weave.diffVersions(USER, v1.databaseId, v3.versionId, v1.versionId); + expect(spanning!.from!.number).toBe(1); + // Across two versions both changes show at once. + const customer = spanning!.compare.tables.find((t) => t.tableName.toUpperCase() === 'CUSTOMER'); + const byName = Object.fromEntries( + customer!.columnDiffs.map((c) => [c.name.toUpperCase(), c.status]) + ); + expect(byName.EMAIL).toBe('MODIFIED'); + expect(byName.PHONE).toBe('ADDED'); + }); + + it('returns the first capture with no earlier side', async () => { + const { weave } = await freshStore(); + const v1 = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' }); + const result = await weave.diffVersions(USER, v1.databaseId, v1.versionId); + expect(result!.from).toBeNull(); + // Everything in v1 reads as added against an empty predecessor. + expect(result!.compare.summary.added).toBeGreaterThan(0); + }); +}); + +describe('revert reuses the comparison logic', () => { + it('plans the reverse of what the version compare reports', async () => { + // Revert and version-compare are the same comparison read in opposite + // directions. If they ever disagree, one of them is lying to the user about + // what a button will do. + const { weave } = await freshStore(); + const v1 = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' }); + const v2 = await weave.capture(USER, { + ...IDENTITY, + tables: [ + table('customer', [ + ['id', 'integer', false], + ['email', 'varchar(255)'], + ['phone', 'text'], + ]), + ], + source: 'migrate', + }); + + // Forward: v2 added `phone` and widened `email`. + const forward = await weave.diffVersions(USER, v1.databaseId, v2.versionId); + const fwd = Object.fromEntries( + forward!.compare.tables + .find((t) => t.tableName.toUpperCase() === 'CUSTOMER')! + .columnDiffs.map((c) => [c.name.toUpperCase(), c.status]) + ); + expect(fwd.PHONE).toBe('ADDED'); + expect(fwd.EMAIL).toBe('MODIFIED'); + + // Reverting to v1 must undo exactly those: drop phone, narrow email back. + const plan = await weave.planRevert(USER, v1.databaseId, v1.versionId); + const keys = plan!.reversal.verdicts.map((v) => v.key); + expect(keys).toContain('column:CUSTOMER.PHONE'); + expect(keys).toContain('column:CUSTOMER.EMAIL'); + + // And it is flagged lossy: varchar(255) → varchar(100) truncates, dropping + // a column loses its values. + expect(plan!.reversal.risk).toBe('lossy'); + }); + + it('treats the picked version as the source, current as the target', async () => { + // The mental model the UI already has: "Original server" is the reference, + // "Target" is what changes. Reverting sets source = the version you picked. + const { weave } = await freshStore(); + const v1 = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' }); + await weave.capture(USER, { + ...IDENTITY, + tables: [CUSTOMER, table('orders', [['id', 'integer', false]])], + source: 'migrate', + }); + + const plan = await weave.planRevert(USER, v1.databaseId, v1.versionId); + // v1 had no `orders`, so reverting to it removes the table. + expect(plan!.fromVersion.number).toBe(2); + expect(plan!.toVersion.number).toBe(1); + expect(plan!.reversal.verdicts.map((v) => v.key)).toContain('table:ORDERS'); + }); +}); diff --git a/apps/web/src/backend/modules/lokee-weave.module.ts b/apps/web/src/backend/modules/lokee-weave.module.ts index 374b85c9..d33ae542 100644 --- a/apps/web/src/backend/modules/lokee-weave.module.ts +++ b/apps/web/src/backend/modules/lokee-weave.module.ts @@ -27,10 +27,12 @@ */ import { createHash, randomUUID } from 'node:crypto'; import { + CompareModule, applyChanges, assembleBlueprint, - buildRevertMigration, + migrationFromCompare, canonicalizeSchema, + changeKindsByOwner, collapseObjectHistory, countSourceLines, databaseIdentity, @@ -38,8 +40,12 @@ import { isLokeeTableLikeType, objectKeyKind, objectKeyOwner, + mergeBody, planReversal, renderLokeeObjectScript, + roundTrips, + shapeKey, + splitBody, weave, type CanonicalObject, type DatabaseIdentityInput, @@ -47,7 +53,9 @@ import { type MigrationStep, type ObjectBlueprint, type ObjectChange, + type ObjectChangeKind, type ReversalPlan, + type SchemaCompareResult, type StoredWeaveObject, type TableSchema, } from '@foxschema/sql'; @@ -62,6 +70,7 @@ import type { ObjectInspectResult, RevertPlanWire, VersionGraphDTO, + VersionCompare, VersionGraphObject, VersionSummary, } from '../../shared/lokee-wire'; @@ -82,6 +91,9 @@ const MAX_BIND_PARAMS = 900; /** Objects returned for one graph window, before the view's own node cap. */ const MAX_GRAPH_OBJECT_KEYS = 400; +/** Versions walked for an object roadmap. Matches listVersions' own ceiling. */ +const MAX_ROADMAP_VERSIONS = 500; + export type { CaptureResult, CaptureSource, @@ -181,10 +193,28 @@ function toCanonical(object: StoredWeaveObject): CanonicalObject { }; } -function canonicalList(objects: Map): CanonicalObject[] { +function canonicalList(objects: ReadonlyMap): CanonicalObject[] { return [...objects.values()].map(toCanonical); } +/** + * Just the objects belonging to one container — the table and its columns, + * indexes, keys and triggers. + * + * Comparing a single object should not walk a 20,000-object schema, and a + * whole-schema compare would also report every *other* table as changed. + */ +function ownerSubtree( + objects: ReadonlyMap, + owner: string +): Map { + const out = new Map(); + for (const [key, object] of objects) { + if (objectKeyOwner(key) === owner) out.set(key, object); + } + return out; +} + /** `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, '!_'); @@ -369,35 +399,80 @@ export class LokeeWeaveStore { const missing = candidates.filter((o) => !present.has(o.hash)); if (missing.length === 0) return; + await this.writeShapes(store, missing); const now = new Date().toISOString(); - const COLUMNS = 8; + const COLUMNS = 9; for (const batch of chunkForBind(missing, COLUMNS)) { - const values = batch.map(() => '(?, ?, ?, ?, ?, ?, ?, ?)').join(', '); + const values = batch.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', '); const params: SqlParam[] = []; for (const object of batch) { const sourceText = object.sourceText ?? (typeof object.body.definition === 'string' ? object.body.definition : null); + // Store the declaration once and point at it. A body that does not + // round-trip is written whole (shape_hash null) — a wrong body is far + // worse than a missed dedup, because the hash was taken over the + // original and a read would no longer reproduce it. + const dedup = roundTrips(object.body); + const split = dedup ? splitBody(object.body) : null; params.push( object.hash, object.key, object.type, typeof object.body.name === 'string' ? object.body.name : null, - JSON.stringify(object.body), + JSON.stringify(split ? split.identity : object.body), now, sourceText ? countSourceLines(sourceText) : null, - sourceText + sourceText, + split ? sha256(shapeKey(split.shape)) : null ); } await store.run( `INSERT INTO lokee_objects - (hash, object_key, object_type, name, body_json, created_at, line_count, source_text) + (hash, object_key, object_type, name, body_json, created_at, line_count, source_text, shape_hash) VALUES ${values}`, params ); } } + /** Insert any shape these objects need that is not already stored. */ + private async writeShapes( + store: MetadataStore, + objects: readonly (CanonicalObject & { hash: string })[] + ): Promise { + const byHash = new Map(); + for (const object of objects) { + if (!roundTrips(object.body)) continue; + const json = shapeKey(splitBody(object.body).shape); + byHash.set(sha256(json), json); + } + if (byHash.size === 0) return; + + const present = new Set(); + for (const batch of chunkForBind([...byHash.keys()], 1)) { + const placeholders = batch.map(() => '?').join(', '); + const rows = await store.all<{ shape_hash: string }>( + `SELECT shape_hash FROM lokee_shapes WHERE shape_hash IN (${placeholders})`, + [...batch] + ); + for (const row of rows) present.add(row.shape_hash); + } + + const missing = [...byHash.entries()].filter(([hash]) => !present.has(hash)); + if (missing.length === 0) return; + const now = new Date().toISOString(); + for (const batch of chunkForBind(missing, 3)) { + const values = batch.map(() => '(?, ?, ?)').join(', '); + const params: SqlParam[] = []; + for (const [hash, json] of batch) params.push(hash, json, now); + await store.run( + `INSERT INTO lokee_shapes (shape_hash, shape_json, created_at) VALUES ${values}`, + params + ); + } + } + private async writeDelta( store: MetadataStore, versionId: string, @@ -767,6 +842,43 @@ export class LokeeWeaveStore { const truncatedObjects = allKeys.length > keys.length; const keySet = new Set(keys); + // What kind of child changed, per container per version. Telling a data-type + // change from a column add needs both bodies, so load the bodies of the + // *delta* hashes only — that is the size of what changed, not of the schema. + const deltaHashes = new Set(); + for (const rows of deltas.values()) { + for (const row of rows) { + if (row.object_hash) deltaHashes.add(row.object_hash); + if (row.previous_hash) deltaHashes.add(row.previous_hash); + } + } + const deltaBodies = new Map>(); + for (const batch of chunkForBind([...deltaHashes], 1)) { + const placeholders = batch.map(() => '?').join(', '); + const rows = await store.all<{ hash: string; body_json: string; shape_json: string | null }>( + `SELECT o.hash, o.body_json, s.shape_json + FROM lokee_objects o + LEFT JOIN lokee_shapes s ON s.shape_hash = o.shape_hash + WHERE o.hash IN (${placeholders})`, + [...batch] + ); + for (const row of rows) deltaBodies.set(row.hash, bodyFromRow(row)); + } + const kindsByVersion = new Map>(); + for (const [versionId, rows] of deltas) { + kindsByVersion.set( + versionId, + changeKindsByOwner( + rows.map((row) => ({ + objectKey: row.object_key, + operation: row.operation, + body: row.object_hash ? deltaBodies.get(row.object_hash) : undefined, + previousBody: row.previous_hash ? deltaBodies.get(row.previous_hash) : undefined, + })) + ) + ); + } + // Names and types for every hash in play, in batches. const hashes = new Set(); for (const state of states.values()) { @@ -811,6 +923,14 @@ export class LokeeWeaveStore { // inventing a node before the object was created. if (hash == null) continue; const info = meta.get(hash); + // A container carries the kinds of its children that moved, so the node + // can say "type, cols" instead of just "modified". Children carry none — + // the badge belongs on the thing the reader is looking at. + const owner = objectKeyOwner(key); + const childKinds = + owner && objectKeyKind(key) !== 'column' + ? kindsByVersion.get(version.id)?.get(owner) + : undefined; objects.push({ versionId: version.id, objectKey: key, @@ -819,6 +939,7 @@ export class LokeeWeaveStore { objectHash: hash, status: row?.operation === 'ADD' ? 'added' : row?.operation === 'MODIFY' ? 'modified' : 'unchanged', + ...(childKinds && childKinds.length > 0 ? { changeKinds: childKinds } : {}), }); } } @@ -883,22 +1004,20 @@ export class LokeeWeaveStore { object_type: string; name: string | null; body_json: string; + shape_json: string | null; source_text: string | null; line_count: number | null; created_at: string | null; }>( - `SELECT hash, object_key, object_type, name, body_json, source_text, line_count, created_at - FROM lokee_objects WHERE hash IN (${placeholders})`, + `SELECT o.hash, o.object_key, o.object_type, o.name, o.body_json, s.shape_json, + o.source_text, o.line_count, o.created_at + FROM lokee_objects o + LEFT JOIN lokee_shapes s ON s.shape_hash = o.shape_hash + WHERE o.hash IN (${placeholders})`, [...batch] ); for (const row of rows) { - let body: Record = {}; - try { - body = JSON.parse(row.body_json) as Record; - } catch { - // A corrupt body must not take down a whole version read; the object - // still exists and its identity is still known. - } + const body = bodyFromRow(row); out.set(row.object_key, { key: row.object_key, hash: row.hash, @@ -939,17 +1058,34 @@ export class LokeeWeaveStore { const tableLike = isLokeeTableLikeType(String(blueprint.container?.type ?? kind)); const script = renderLokeeObjectScript(blueprint); let previousScript = ''; + let previousState = new Map(); const versions = await this.listVersions(userId, databaseId, 500); const here = versions.findIndex((v) => v.id === versionId); const older = here >= 0 ? versions[here + 1] : undefined; if (older) { // 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)); + previousState = await this.objectsAtVersion(userId, databaseId, older.id); + previousScript = renderLokeeObjectScript(assembleBlueprint(objectKey, previousState)); } + + // The inspector's blueprint tables are the *same* component Compare Schema + // renders, so give them the same input: a TableDiff. Both states are already + // in hand, so this costs one compare over a single object's subtree — not + // the whole schema, which is why the maps are narrowed first. + // + // Direction is Compare's own, and matches `diffVersions`: source = the newer + // state, so ADDED reads as "this version added it". + const dialect = await this.dialectOf(store, databaseId); + const compare = await this.compareVersionStates( + ownerSubtree(stored, owner), + ownerSubtree(previousState, owner), + dialect + ); + return { blueprint, + diff: compare.tables.find((t) => t.tableName === owner) ?? null, history: await this.objectHistory(userId, databaseId, objectKey), growth: tableLike ? await this.containerGrowth(userId, databaseId, owner) : [], columnMutations: tableLike ? await this.columnMutations(userId, databaseId, owner) : [], @@ -969,8 +1105,33 @@ export class LokeeWeaveStore { databaseId: string, toVersionId: string, dialect?: string, - schema?: string + schema?: string, + /** + * Revert only these objects. Omit for the whole schema. + * + * Selecting a container pulls its children in: reverting `table:CUSTOMER` + * without its columns would apply half a change and leave the table in a + * state no version ever held. + */ + objectKeys?: readonly string[] ): Promise { + // An explicit empty selection is a no-op plan, not a whole-schema revert. + if (objectKeys !== undefined && objectKeys.length === 0) { + const store0 = await this.store(); + if (!(await this.assertOwned(store0, userId, databaseId))) return null; + const all = await this.listVersions(userId, databaseId, 500); + const head = all[0]; + const target = all.find((v) => v.id === toVersionId); + if (!head || !target) return null; + return { + fromVersion: head, + toVersion: target, + alreadyAtTarget: true, + reversal: planReversal([]), + steps: [], + statements: [], + }; + } const store = await this.store(); if (!(await this.assertOwned(store, userId, databaseId))) return null; @@ -991,8 +1152,24 @@ export class LokeeWeaveStore { const current = await this.objectsAtVersion(userId, databaseId, fromVersion.id); const desired = await this.objectsAtVersion(userId, databaseId, toVersion.id); + // `undefined` means "no filter given" — revert the whole schema. + // `[]` means "nothing selected", which must revert *nothing*: a UI with no + // boxes ticked sending an empty list must not wipe the database. + const selected = objectKeys === undefined ? null : new Set(objectKeys); + // Owners of the selected containers, so their children come along. + const selectedOwners = selected + ? new Set([...selected].filter((k) => !k.includes('.')).map((k) => objectKeyOwner(k))) + : null; + const wanted = (key: string): boolean => { + if (!selected) return true; + if (selected.has(key)) return true; + const owner = objectKeyOwner(key); + return owner ? selectedOwners!.has(owner) : false; + }; + const entries: Array<{ key: string; current?: CanonicalObject; target?: CanonicalObject }> = []; for (const key of new Set([...current.keys(), ...desired.keys()])) { + if (!wanted(key)) continue; const cur = current.get(key); const tgt = desired.get(key); if (cur && tgt && cur.hash === tgt.hash) continue; @@ -1014,10 +1191,13 @@ export class LokeeWeaveStore { schemaName ??= db?.schema ?? undefined; } + // Revert is a migration whose source lives in the object store: the version + // the user picked is the reference ("Original server"), the current head is + // what changes to match it ("Target"). Same primitive as version compare, + // then the same SQL generator the live migrate flow uses. const migration = dialectName - ? await buildRevertMigration( - hydrateTableSchemas(canonicalList(current)), - hydrateTableSchemas(canonicalList(desired)), + ? migrationFromCompare( + await this.compareVersionStates(desired, current, dialectName, schemaName), dialectName, { targetSchema: schemaName, sourceSchema: schemaName } ) @@ -1114,17 +1294,16 @@ export class LokeeWeaveStore { body_json: string; line_count: number | null; created_at: string | null; + shape_json: string | null; }>( - `SELECT hash, body_json, line_count, created_at FROM lokee_objects WHERE hash IN (${placeholders})`, + `SELECT o.hash, o.body_json, s.shape_json, o.line_count, o.created_at + FROM lokee_objects o + LEFT JOIN lokee_shapes s ON s.shape_hash = o.shape_hash + WHERE o.hash IN (${placeholders})`, [...batch] ); for (const row of found) { - let body: Record = {}; - try { - body = JSON.parse(row.body_json) as Record; - } catch { - /* keep empty */ - } + const body = bodyFromRow(row); bodies.set(row.hash, { body, lineCount: row.line_count, @@ -1140,7 +1319,10 @@ export class LokeeWeaveStore { databaseId: string, owner: string ): Promise { - const versions = await this.listVersions(userId, databaseId, 20); + // The whole history, not a recent window: a roadmap that starts at v(N-20) + // hides the moment a table was created, which is the point a reader looks + // for first. Still bounded — listVersions caps at 500. + const versions = await this.listVersions(userId, databaseId, MAX_ROADMAP_VERSIONS); if (versions.length === 0) return []; const store = await this.store(); const latest = await this.loadLatestIndex(store, databaseId); @@ -1152,6 +1334,12 @@ export class LokeeWeaveStore { const points: ContainerGrowthPoint[] = []; for (const version of [...versions].reverse()) { const state = states.get(version.id) ?? new Map(); + // Did anything under this container move in this version? A hundred + // versions of an untouched table is a flat line, and the reader needs the + // few points that are not. + const changed = (deltas.get(version.id) ?? []).some( + (row) => objectKeyOwner(row.object_key) === owner + ); let columns = 0; let indexes = 0; let foreignKeys = 0; @@ -1175,11 +1363,96 @@ export class LokeeWeaveStore { foreignKeys, triggers, objects, + changed, }); } return points; } + /** + * Compare two stored versions with the app's own Compare engine. + * + * One primitive, two callers, so revert and version-compare cannot drift + * apart. The direction is Compare's own: `source` is the reference — what the + * schema *should* look like — and `target` is the side that would change to + * match it. + * + * version compare → source = newer, target = older ("what did this add?") + * revert → source = the version you picked, target = current + * + * Revert is therefore not a special kind of diff; it is a migration whose + * source happens to live in the object store rather than on a server. + */ + private async compareVersionStates( + sourceState: ReadonlyMap, + targetState: ReadonlyMap, + dialect?: string, + schema?: string + ): Promise { + return new CompareModule().compare( + hydrateTableSchemas(canonicalList(sourceState)), + hydrateTableSchemas(canonicalList(targetState)), + { source: dialect, target: dialect }, + schema ? { source: schema, target: schema } : undefined + ); + } + + /** + * Diff one version against another, from the object store alone. + * + * Needs no connection: both states are reconstructable, so a user can compare + * v3 to v7 on a database that is offline or long gone. `toVersionId` defaults + * to the version's own parent, which is the "what did this migrate do?" case. + */ + async diffVersions( + userId: string, + databaseId: string, + versionId: string, + againstVersionId?: string + ): Promise { + const store = await this.store(); + if (!(await this.assertOwned(store, userId, databaseId))) return null; + + const versions = await this.listVersions(userId, databaseId, 500); + const to = versions.find((v) => v.id === versionId); + if (!to) return null; + // Default to the adjacent older version — the change this version made. + const from = + againstVersionId + ? versions.find((v) => v.id === againstVersionId) + : versions.find((v) => v.number === to.number - 1); + + const toState = await this.objectsAtVersion(userId, databaseId, to.id); + const fromState = from + ? await this.objectsAtVersion(userId, databaseId, from.id) + : new Map(); + + // Rebuild the nested shape Compare already speaks and run the *same* engine + // the live Compare Schema flow uses, rather than a second diff kept in step + // by hand. Stored objects fully describe the schema, so no connection is + // involved. + // + // Direction matters and is easy to get backwards: Compare answers "what must + // TARGET change to match SOURCE" (that is the migrate direction — source is + // the reference). So the *newer* version is the source and the older is the + // target, which makes ADDED mean "this version added it". Passing them the + // other way round reports every addition as a removal, which is exactly + // what it did before this was checked against real history. + const dialect = await this.dialectOf(store, databaseId); + const compare = await this.compareVersionStates(toState, fromState, dialect); + + return { from: from ?? null, to, compare, dialect: dialect ?? null }; + } + + /** Dialect recorded for this database, for the compare engine's type rules. */ + private async dialectOf(store: MetadataStore, databaseId: string): Promise { + const row = await store.get<{ dialect: string }>( + 'SELECT dialect FROM lokee_databases WHERE id = ?', + [databaseId] + ); + return row?.dialect; + } + /** * Delete object bodies no version references any more. * @@ -1199,6 +1472,31 @@ export class LokeeWeaveStore { } } + +/** + * Rebuild a body from its stored halves. + * + * `shape_json` is null for rows written before the dedup migration and for any + * body that did not round-trip; those kept their whole body in `body_json`, so + * returning it unchanged is correct rather than a fallback. + */ +function bodyFromRow(row: { body_json: string; shape_json?: string | null }): Record { + let identity: Record = {}; + try { + identity = JSON.parse(row.body_json) as Record; + } catch { + // A body we cannot parse yields an empty object rather than taking down the + // whole read; the object's identity and hash are still known. + return {}; + } + if (!row.shape_json) return identity; + try { + return mergeBody({ identity, shape: JSON.parse(row.shape_json) as Record }); + } catch { + return identity; + } +} + /** `column:CUSTOMER.EMAIL` → `CUSTOMER.EMAIL`, for rows with no stored name. */ function fallbackName(objectKey: string): string { const colon = objectKey.indexOf(':'); diff --git a/apps/web/src/frontend/api/lokeeApi.ts b/apps/web/src/frontend/api/lokeeApi.ts index 8c4914ba..438bf9d7 100644 --- a/apps/web/src/frontend/api/lokeeApi.ts +++ b/apps/web/src/frontend/api/lokeeApi.ts @@ -22,6 +22,7 @@ import type { ObjectHistoryEntry, ObjectInspectResult, RevertPlanWire, + VersionCompare, VersionSummary, } from '../../shared/lokee-wire'; import { getApiBase, parseJsonBody, parseJsonResponse } from './apiBase'; @@ -136,9 +137,12 @@ export class LokeeRevertError extends Error { /** Classify a revert to `toVersionId` and preview the reverse DDL. */ export async function planLokeeRevert( databaseId: string, - toVersionId: string + toVersionId: string, + /** Plan a revert of only these objects; omit for the whole schema. */ + objectKeys?: readonly string[] ): Promise { const params = new URLSearchParams({ toVersionId }); + for (const key of objectKeys ?? []) params.append('objectKeys', key); const res = await fetch( `${getApiBase()}/lokee/databases/${encodeURIComponent(databaseId)}/revert/plan?${params}`, { credentials: 'include' } @@ -159,6 +163,8 @@ export async function executeLokeeRevert( connectionId: string; password?: string; confirmLossy?: boolean; + /** Revert only these objects; omit for the whole schema. */ + objectKeys?: readonly string[]; } ): Promise { const res = await fetch( @@ -186,3 +192,25 @@ export async function executeLokeeRevert( data.fromVersion ? data : undefined ); } + +/** + * Diff a version against another (its parent by default). + * + * Reads the object store, so this works on a database that is offline or no + * longer reachable — the whole point of keeping the objects. + */ +export async function compareLokeeVersions( + databaseId: string, + versionId: string, + againstVersionId?: string +): Promise { + const params = new URLSearchParams({ versionId }); + if (againstVersionId) params.set('againstVersionId', againstVersionId); + const res = await fetch( + `${getApiBase()}/lokee/databases/${encodeURIComponent(databaseId)}/compare?${params}`, + { credentials: 'include' } + ); + return parseJsonResponse(res); +} + +export type { VersionCompare } from '../../shared/lokee-wire'; diff --git a/apps/web/src/frontend/components/DetailTabs.tsx b/apps/web/src/frontend/components/DetailTabs.tsx new file mode 100644 index 00000000..90fde8ad --- /dev/null +++ b/apps/web/src/frontend/components/DetailTabs.tsx @@ -0,0 +1,68 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * The three questions every migration surface answers, and the tab bar that + * asks them: what changed, what the DDL looks like, what SQL will run. + * + * Compare Schema had these tabs; version history grew its own trio with + * different labels ("Blueprint / DDL / Execute") and different chrome, so the + * same flow looked like two features. The ids and labels live here now, which + * is what keeps them from drifting again. + */ +import React from 'react'; +import { Code, FileText, GitCompareArrows } from 'lucide-react'; + +export type DetailTab = 'DIFF' | 'DDL_DIFF' | 'SQL'; + +export const DETAIL_TABS: Record = { + DIFF: { label: 'Schema Blueprint', icon: }, + DDL_DIFF: { label: 'DDL Diff', icon: }, + SQL: { label: 'Migration SQL', icon: }, +}; + +export interface DetailTabsProps { + active: DetailTab; + onSelect: (tab: DetailTab) => void; + /** Which tabs to show. Browse mode has nothing to compare, so it shows one. */ + tabs?: readonly DetailTab[]; + /** Prefix for `data-testid`, e.g. `lokee-cmp` → `lokee-cmp-tab-SQL`. */ + testIdPrefix?: string; + /** `compact` trims the padding for a modal pane. */ + size?: 'default' | 'compact'; +} + +const ALL_TABS: readonly DetailTab[] = ['DIFF', 'DDL_DIFF', 'SQL']; + +export function DetailTabs({ + active, + onSelect, + tabs = ALL_TABS, + testIdPrefix, + size = 'default', +}: DetailTabsProps): React.ReactElement { + const pad = size === 'compact' ? 'px-2 py-1' : 'px-3 py-1.5'; + return ( +
+ {tabs.map((id) => { + const { label, icon } = DETAIL_TABS[id]; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/frontend/components/ObjectDetailPanel.tsx b/apps/web/src/frontend/components/ObjectDetailPanel.tsx index 656426ca..0c706ea7 100644 --- a/apps/web/src/frontend/components/ObjectDetailPanel.tsx +++ b/apps/web/src/frontend/components/ObjectDetailPanel.tsx @@ -10,6 +10,13 @@ import { diffLines } from '../utils/lineDiff'; import { highlightMatch } from '../utils/highlight'; import { formatSql } from '../utils/formatSql'; import type { TableDiff } from '../lib/types'; +import { SchemaBlueprint } from './SchemaBlueprint'; +import { DetailTabs, type DetailTab } from './DetailTabs'; +import { + buildTableDdlDiffLines, + DdlDiffLines, + stripSchemaQualifiers, +} from './SchemaDdlDiff'; import { MigrationProgressPanel } from './object-detail/MigrationProgressPanel'; import { DeployConfirmDialog } from './object-detail/DeployConfirmDialog'; import { DependencyWarningDialog } from './object-detail/DependencyWarningDialog'; @@ -38,125 +45,6 @@ function formatSqlBounded(sql: string, dialect: string): string { // Persisted "skip the deploy confirmation" preference. const SKIP_DEPLOY_CONFIRM_KEY = 'foxschema-skip-deploy-confirm'; -// ── Status-driven DDL diff for tables ──────────────────────────────────────── -// A raw text diff of two rendered CREATE TABLEs mis-reads a column *reorder* as a -// change, and colours a source-only column as a deletion — when it's really an ADD -// the migration will apply to the target. So for tables we drive the colouring from -// the already-correct columnDiffs instead: render the source (the desired end state) -// column-by-column, tag each line by its ColumnDiff status, then append the -// target-only (REMOVED) columns. Same source of truth the column table uses. -type DdlLineKind = 'added' | 'removed' | 'modified' | 'neutral'; -interface DdlDiffLine { kind: DdlLineKind; marker: string; text: string; } - -const kindForStatus = (status?: string): DdlLineKind => - status === 'ADDED' ? 'added' - : status === 'REMOVED' ? 'removed' - : status === 'MODIFIED' ? 'modified' - : 'neutral'; - -const markerForKind = (kind: DdlLineKind): string => - kind === 'added' ? '+' : kind === 'removed' ? '-' : kind === 'modified' ? '~' : ' '; - -// Colour trailing CREATE INDEX / ADD CONSTRAINT lines by their own diff status. -const trailingLineKind = (text: string, diff: TableDiff, baseIsSource: boolean): DdlLineKind => { - // security/detect-unsafe-regex false-positives here: no nested/overlapping - // quantifiers, so no catastrophic backtracking — verified against a 50k-char - // all-whitespace input at <1ms. - // eslint-disable-next-line security/detect-unsafe-regex - const idx = text.match(/^\s*CREATE(?:\s+UNIQUE)?\s+INDEX\s+(\S+)\s+ON\b/i); - if (idx) { - const st = (diff.indexDiffs ?? []).find((d) => d.name.toUpperCase() === idx[1].toUpperCase())?.status; - return baseIsSource ? kindForStatus(st) : 'removed'; - } - const fk = text.match(/ADD\s+CONSTRAINT\s+(\S+)\s+FOREIGN\s+KEY\b/i); - if (fk) { - const st = (diff.foreignKeyDiffs ?? []).find((d) => d.name.toUpperCase() === fk[1].toUpperCase())?.status; - return baseIsSource ? kindForStatus(st) : 'removed'; - } - return 'neutral'; -}; - -function buildTableDdlDiffLines( - diff: TableDiff, - sourceDialect: string, - targetDialect: string, - strip: (ddl: string) => string, -): DdlDiffLine[] { - const src = diff.sourceTable; - const tgt = diff.targetTable; - const base = src ?? tgt; - if (!base) return []; - const baseIsSource = !!src; - - const colStatus = new Map(); - for (const c of diff.columnDiffs ?? []) colStatus.set(c.name.toUpperCase(), c.status); - - const baseDialect = baseIsSource ? sourceDialect : targetDialect; - // Render the table WITHOUT triggers — generateObjectDdl only appends the raw trigger - // body (Oracle stores no CREATE TRIGGER header) and can't colour it, so we render - // triggers ourselves below with a name header + status colour. - const baseLines = strip(ddlGenerator.generateObjectDdl({ ...base, triggers: [] }, baseDialect)).split('\n'); - const out: DdlDiffLine[] = []; - - // renderCreateTable emits: header, one line per base.columns (in order), an optional - // PK line, then ");" — so column N is baseLines[1 + N]. - out.push({ kind: 'neutral', marker: ' ', text: baseLines[0] ?? `CREATE TABLE ${base.name} (` }); - let li = 1; - for (let c = 0; c < base.columns.length; c++, li++) { - const kind = baseIsSource ? kindForStatus(colStatus.get(base.columns[c].name.toUpperCase())) : 'removed'; - out.push({ kind, marker: markerForKind(kind), text: baseLines[li] ?? '' }); - } - - // Target-only columns the migration will DROP — pull their rendered line from the - // target side and slot them in after the desired column set. - if (src && tgt) { - const tgtLines = strip(ddlGenerator.generateObjectDdl({ ...tgt, triggers: [] }, targetDialect)).split('\n'); - tgt.columns.forEach((col, t) => { - if (colStatus.get(col.name.toUpperCase()) === 'REMOVED') { - out.push({ kind: 'removed', marker: '-', text: tgtLines[1 + t] ?? ` ${col.name}` }); - } - }); - } - - // PK line, ");", and any CREATE INDEX / ADD CONSTRAINT lines appended after. - for (; li < baseLines.length; li++) { - const text = baseLines[li]; - if (text === undefined) continue; - const kind = trailingLineKind(text, diff, baseIsSource); - out.push({ kind, marker: markerForKind(kind), text }); - } - - // Triggers — rendered from triggerDiffs so we surface the name (Oracle keeps only the - // raw body) and colour by status. Desired end-state triggers (source) first, then - // target-only (REMOVED) ones. - const pushTrigger = ( - name: string, - trg: { timing?: string; event?: string; definition?: string }, - kind: DdlLineKind, - ) => { - const marker = markerForKind(kind); - const meta = [trg.timing, trg.event].filter(Boolean).join(' '); - out.push({ kind, marker, text: `-- TRIGGER ${name}${meta ? ` (${meta})` : ''}` }); - const body = (trg.definition ?? '').trim(); - if (body) for (const bl of body.split('\n')) out.push({ kind, marker, text: bl }); - else out.push({ kind, marker, text: ' -- no definition available' }); - }; - const trigDiffs = diff.triggerDiffs ?? []; - for (const td of trigDiffs) { - if (td.status === 'REMOVED') continue; - const trg = td.source ?? td.target; - if (trg) pushTrigger(td.name, trg, baseIsSource ? kindForStatus(td.status) : 'removed'); - } - for (const td of trigDiffs) { - if (td.status === 'REMOVED' && td.target) pushTrigger(td.name, td.target, 'removed'); - } - - // Trim trailing blank lines left by the DDL generator. - while (out.length && out[out.length - 1].text.trim() === '') out.pop(); - - return out; -} - export const ObjectDetailPanel: React.FC = () => { const canMigrate = useAuthStore((s) => s.can('schema.migrate')); const { @@ -186,7 +74,7 @@ export const ObjectDetailPanel: React.FC = () => { const includedCount = Object.values(syncSelection).filter(Boolean).length; - const [activeTab, setActiveTab] = useState<'DIFF' | 'DDL_DIFF' | 'SQL'>('DIFF'); + const [activeTab, setActiveTab] = useState('DIFF'); const [copied, setCopied] = useState(false); const [expandedTriggers, setExpandedTriggers] = useState>({}); // Matches the case-insensitive schema compare; toggle off to inspect raw identifier casing @@ -278,15 +166,8 @@ export const ObjectDetailPanel: React.FC = () => { if (!selectedTable || selectedTable.objectType === 'TABLE' || activeTab !== 'DDL_DIFF') { return { sourceDdl: '', targetDdl: '' }; } - const stripSchemas = (ddl: string) => { - const schemas = [sourceConfig.schema, targetConfig.schema].filter(Boolean); - let out = ddl; - for (const s of schemas) { - out = out.replace(new RegExp(`\\b${s}\\.`, 'gi'), ''); - out = out.replace(new RegExp(`"${s}"\\s*\\.\\s*`, 'gi'), ''); - } - return out; - }; + const stripSchemas = (ddl: string) => + stripSchemaQualifiers(ddl, [sourceConfig.schema, targetConfig.schema]); const rawSource = selectedTable.sourceTable ? ddlGenerator.generateObjectDdl(selectedTable.sourceTable, sourceConfig.dialect) : ''; @@ -373,15 +254,8 @@ export const ObjectDetailPanel: React.FC = () => { // Tables: status-driven colouring from columnDiffs (aligns by column name, colours // by what the migration DOES — see buildTableDdlDiffLines). Everything else // (views/functions/triggers/sequences) keeps the Monaco text diff. - const stripSchemas = (ddl: string) => { - const schemas = [sourceConfig.schema, targetConfig.schema].filter(Boolean); - let out = ddl; - for (const s of schemas) { - out = out.replace(new RegExp(`\\b${s}\\.`, 'gi'), ''); - out = out.replace(new RegExp(`"${s}"\\s*\\.\\s*`, 'gi'), ''); - } - return out; - }; + const stripSchemas = (ddl: string) => + stripSchemaQualifiers(ddl, [sourceConfig.schema, targetConfig.schema]); const tableLines = isTable ? buildTableDdlDiffLines(selectedTable, sourceConfig.dialect, targetConfig.dialect, stripSchemas) : []; @@ -455,30 +329,7 @@ export const ObjectDetailPanel: React.FC = () => {
{isTable ? (
- - - {tableLines.map((line, i) => { - const textClass = - line.kind === 'added' ? 'text-emerald-300' - : line.kind === 'removed' ? 'text-rose-300' - : line.kind === 'modified' ? 'text-amber-300' - : 'text-slate-300'; - const rowBg = - line.kind === 'added' ? 'bg-emerald-500/10' - : line.kind === 'removed' ? 'bg-rose-500/10' - : line.kind === 'modified' ? 'bg-amber-500/10' - : ''; - return ( - - - - - ); - })} - -
{line.marker} - {highlightMatch(line.text, q)} -
+
) : (
@@ -500,104 +351,11 @@ export const ObjectDetailPanel: React.FC = () => { ); }; - // Renders one side's column state, highlighting the attributes that differ from the other side - const renderColumnState = ( - own?: { type: string; nullable: boolean; defaultValue?: string; primaryKey?: boolean; identity?: boolean }, - other?: { type: string; nullable: boolean; defaultValue?: string; primaryKey?: boolean; identity?: boolean } - ) => { - if (!own) return none; - - const hl = 'text-amber-300 bg-amber-500/15 rounded px-1'; - const typeChanged = !!other && own.type.toLowerCase() !== other.type.toLowerCase(); - const nullChanged = !!other && own.nullable !== other.nullable; - const defChanged = !!other && (own.defaultValue ?? null) !== (other.defaultValue ?? null); - const pkChanged = !!other && !!own.primaryKey !== !!other.primaryKey; - const identityChanged = !!other && !!own.identity !== !!other.identity; - const hasDefault = own.defaultValue !== undefined && own.defaultValue !== null; - - return ( - - {own.type} - - {(!own.nullable || nullChanged) && ( - {own.nullable ? 'NULL' : 'NOT NULL'} - )} - - {hasDefault ? ( - DEFAULT {own.defaultValue} - ) : defChanged ? ( - no default - ) : null} - - {own.primaryKey ? ( - - PRIMARY KEY - - ) : pkChanged ? ( - not PK - ) : null} - - {own.identity ? ( - - IDENTITY - - ) : identityChanged ? ( - not identity - ) : null} - - ); - }; - const renderSchemaObjectDiff = () => { // Highlight the object-browser search keyword in the blueprint (e.g. a // matched column name), mirroring the SQL panels. const query = searchTerm.trim().toLowerCase(); - // Role member deploy selection (changed members only). - const isRole = selectedTable.objectType === 'ROLE'; - const roleChangedMembers = isRole ? selectedTable.columnDiffs.filter((c) => c.status !== 'UNCHANGED') : []; - const allMembersSelected = - roleChangedMembers.length > 0 && - roleChangedMembers.every((m) => memberSelection[selectedTable.tableName]?.[m.name] !== false); - // Index deploy selection (changed indexes only) — opt-IN, so an index change - // is excluded from the migration unless the user explicitly checks it. - const indexChangedItems = selectedTable.indexDiffs.filter((i) => i.status !== 'UNCHANGED'); - const allIndexesSelected = - indexChangedItems.length > 0 && - indexChangedItems.every((i) => indexSelection[selectedTable.tableName]?.[i.name] === true); - // Hide UNCHANGED items unless the "Show unchanged" toggle is on. - // Browse mode synthesizes every field as UNCHANGED (no comparison) — always - // show them, otherwise the blueprint is empty and browsing looks broken. - const keep = (status: string) => browseMode || showUnchangedDetail || status !== 'UNCHANGED'; - const colDiffs = selectedTable.columnDiffs.filter((c) => keep(c.status)); - const indexDiffs = selectedTable.indexDiffs.filter((i) => keep(i.status)); - const fkDiffs = selectedTable.foreignKeyDiffs.filter((f) => keep(f.status)); - const trgDiffs = (selectedTable.triggerDiffs ?? []).filter((t) => keep(t.status)); - - // Counts for the summary — always over the FULL set (independent of the - // show-unchanged toggle). `original` is the count present in the original - // (target); ADDED items don't exist there yet. - const stat = (arr: { status: string }[]) => ({ - original: arr.filter((x) => x.status !== 'ADDED').length, - added: arr.filter((x) => x.status === 'ADDED').length, - modified: arr.filter((x) => x.status === 'MODIFIED').length, - removed: arr.filter((x) => x.status === 'REMOVED').length, - }); - const summary = [ - { label: 'Columns', s: stat(selectedTable.columnDiffs) }, - { label: 'Indexes', s: stat(selectedTable.indexDiffs) }, - { label: 'Foreign Keys', s: stat(selectedTable.foreignKeyDiffs) }, - { label: 'Triggers', s: stat(selectedTable.triggerDiffs ?? []) }, - ]; - return (
{/* Table Overview Header */} @@ -670,611 +428,44 @@ export const ObjectDetailPanel: React.FC = () => {
- {/* Change summary — original count + added/modified/removed per category */} -
- {summary.map(({ label, s }) => ( -
-
- {label} - - {s.original} - -
-
- {s.added > 0 && +{s.added} added} - {s.modified > 0 && ~{s.modified} modified} - {s.removed > 0 && -{s.removed} removed} - {s.added === 0 && s.modified === 0 && s.removed === 0 && no changes} -
-
- ))} -
- - {/* Routine Parameters (functions & procedures) */} - {(selectedTable.objectType === 'FUNCTION' || selectedTable.objectType === 'PROCEDURE') && (() => { - const routine = selectedTable.sourceTable ?? selectedTable.targetTable; - const params = routine?.parameters ?? []; - const modeCls = (m: string) => - m === 'RETURN' || m === 'RESULT' - ? 'text-emerald-300 bg-emerald-950/40 border-emerald-500/25' - : m === 'OUT' || m === 'INOUT' - ? 'text-amber-300 bg-amber-950/40 border-amber-500/25' - : 'text-slate-300 bg-slate-800 border-slate-700/50'; - return ( -
-

- - Parameters - {selectedTable.objectType === 'FUNCTION' && routine?.functionKind && ( - - {routine.functionKind}-valued - - )} -

- {params.length === 0 ? ( -

No parameters.

- ) : ( -
- - - - - - - - - - {params.map((p, i) => ( - - - - - - ))} - -
ParameterTypeMode
- {p.name || (unnamed)} - {p.type} - {p.mode} -
+ {/* The blueprint tables — the same component the version-history compare + renders, so a stored diff and a live diff never look different. */} + toggleMemberSelection(selectedTable.tableName, name)} + onSelectAllMembers={(checked) => setAllMemberSelection(selectedTable.tableName, checked)} + indexSelection={indexSelection[selectedTable.tableName]} + onToggleIndex={(name) => toggleIndexSelection(selectedTable.tableName, name)} + onSelectAllIndexes={(checked) => setAllIndexSelection(selectedTable.tableName, checked)} + expandedTriggers={expandedTriggers} + onToggleTrigger={toggleTriggerDdl} + triggerDdls={formattedTriggerDdls} + ignoreCase={ignoreCase} + definitionSlot={ + selectedTable.objectType !== 'TABLE' && + (selectedTable.sourceTable?.definition || selectedTable.targetTable?.definition) ? ( +
+

+ Source DDL Definition +

+
+ }> + +
- )} -
- ); - })()} - - {/* Sequence / Type Attribute Section */} - {(selectedTable.objectType === 'SEQUENCE' || selectedTable.objectType === 'TYPE') && (() => { - const isSeq = selectedTable.objectType === 'SEQUENCE'; - const src: any = isSeq ? selectedTable.sourceTable?.sequence : selectedTable.sourceTable?.userType; - const tgt: any = isSeq ? selectedTable.targetTable?.sequence : selectedTable.targetTable?.userType; - const rows: { label: string; key: string }[] = isSeq - ? [ - { label: 'Data Type', key: 'dataType' }, - { label: 'Start', key: 'start' }, - { label: 'Increment', key: 'increment' }, - { label: 'Min Value', key: 'minValue' }, - { label: 'Max Value', key: 'maxValue' }, - { label: 'Cycle', key: 'cycle' }, - { label: 'Cache', key: 'cache' }, - ] - : [ - { label: 'Source Type', key: 'sourceType' }, - { label: 'Meta Type', key: 'metaType' }, - ]; - const fmt = (v: any) => (v === undefined || v === null || v === '' ? '—' : String(v)); - - return ( -
-

- - {isSeq ? 'Sequence Attributes' : 'Type Definition'} -

-
- - - - - - - - - - - {rows.map((r) => { - const sv = fmt(src?.[r.key]); - const tv = fmt(tgt?.[r.key]); - const changed = sv !== tv; - return ( - - - - - - - ); - })} - -
AttributeOriginal ServerCompareTarget
{r.label}{sv}{tv}
- - {/* Structured type member attributes */} - {!isSeq && ((src?.attributes?.length ?? 0) > 0 || (tgt?.attributes?.length ?? 0) > 0) && (() => { - const sAttrs: { name: string; type: string }[] = src?.attributes ?? []; - const tAttrs: { name: string; type: string }[] = tgt?.attributes ?? []; - const tMap = new Map(tAttrs.map((a) => [a.name.toUpperCase(), a])); - const sMap = new Map(sAttrs.map((a) => [a.name.toUpperCase(), a])); - const names = Array.from(new Set([...sAttrs.map((a) => a.name), ...tAttrs.map((a) => a.name)])); - return ( -
-
Attributes
-
- - - - - - - - - - - {names.map((n) => { - const sa = sMap.get(n.toUpperCase()); - const ta = tMap.get(n.toUpperCase()); - const changed = (sa?.type ?? '') !== (ta?.type ?? ''); - return ( - - - - - - - ); - })} - -
AttributeOriginal TypeCompareTarget Type
{n}{sa?.type ?? none}{ta?.type ?? none}
-
-
- ); - })()} -
- ); - })()} - - {/* Columns Diff Section (Only show if columns present, e.g., Tables or Views) */} - {colDiffs.length > 0 && ( -
-

- {isRole ? 'Members' : 'Column Blueprint / Attributes'} - {isRole && roleChangedMembers.length > 0 && ( - - )} -

-
- - - - - - - - - - - - {colDiffs.map((col) => { - let opBadge = ( - - No Change - - ); - let rowBg = 'hover:bg-slate-900/20'; - - if (col.status === 'ADDED') { - opBadge = ( - - ADD COLUMN - - ); - rowBg = 'bg-emerald-950/10 hover:bg-emerald-950/20'; - } else if (col.status === 'REMOVED') { - opBadge = ( - - DROP COLUMN - - ); - rowBg = 'bg-rose-950/10 hover:bg-rose-950/20'; - } else if (col.status === 'MODIFIED') { - opBadge = ( - - ALTER TYPE - - ); - rowBg = 'bg-amber-950/10 hover:bg-amber-950/20'; - } - - const isPk = col.source?.primaryKey || col.target?.primaryKey; - - return ( - - - - - - - - ); - })} - -
{selectedTable.objectType === 'ROLE' ? 'Member' : 'Column Name'}Original StateCompareTarget StateOperation
- - {selectedTable.objectType === 'ROLE' && col.status !== 'UNCHANGED' && ( - toggleMemberSelection(selectedTable.tableName, col.name)} - title="Include this member in the deploy script" - className="w-3.5 h-3.5 accent-cyan-500 cursor-pointer shrink-0" - /> - )} - {highlightMatch(col.name, query)} - {isPk && } - - - {renderColumnState(col.source, col.target)} - - - - {renderColumnState(col.target, col.source)} - {opBadge}
-
-
- )} - - {/* Primary Key Diff Section */} - {selectedTable.objectType === 'TABLE' && (() => { - const srcPk = selectedTable.sourceTable?.primaryKey; - const tgtPk = selectedTable.targetTable?.primaryKey; - const pkChanged = JSON.stringify(srcPk?.columns ?? []) !== JSON.stringify(tgtPk?.columns ?? []); - - let opBadge = No Change; - let rowBg = 'hover:bg-slate-900/10'; - if (srcPk && !tgtPk) { - opBadge = ADD PRIMARY KEY; - rowBg = 'bg-emerald-950/10'; - } else if (!srcPk && tgtPk) { - opBadge = DROP PRIMARY KEY; - rowBg = 'bg-rose-950/10'; - } else if (srcPk && tgtPk && pkChanged) { - opBadge = RECREATE; - rowBg = 'bg-amber-950/10'; + ) : null } - - return ( -
-

- Primary Key -

-
- - - - - - - - - - - - {!srcPk && !tgtPk ? ( - - - - ) : ( - - - - - - - - )} - -
Constraint NameOriginal ColumnsCompareTarget ColumnsOperation
- No primary key defined on this table -
- - - {srcPk?.name ?? tgtPk?.name ?? '—'} - - - {srcPk ? srcPk.columns.join(', ') : none} - - - - {tgtPk ? tgtPk.columns.join(', ') : none} - {opBadge}
-
-
- ); - })()} - - {/* View / Function / Procedure Definition — below the column blueprint */} - {selectedTable.objectType !== 'TABLE' && (selectedTable.sourceTable?.definition || selectedTable.targetTable?.definition) && ( -
-

- Source DDL Definition -

-
- }> - - -
-
- )} - - {/* Indices Diff Section */} - {indexDiffs.length > 0 && ( -
-

- Table Indexes - {indexChangedItems.length > 0 && ( - - )} -

- {indexChangedItems.some((i) => i.nameOnly) && ( -

- Same columns under a different name — optional; check an index to include DROP/CREATE in the migration. -

- )} -
- - - - - - - - - - - {indexDiffs.map((idx) => { - const info = idx.source || idx.target; - let opBadge = No Change; - if (idx.status === 'ADDED') { - opBadge = idx.nameOnly - ? CREATE (rename) - : CREATE INDEX; - } else if (idx.status === 'REMOVED') { - opBadge = idx.nameOnly - ? DROP (rename) - : DROP INDEX; - } - - return ( - - - - - - - ); - })} - -
Index NameColumnsConstraintOperation
- - {idx.status !== 'UNCHANGED' && ( - toggleIndexSelection(selectedTable.tableName, idx.name)} - title={idx.nameOnly - ? 'Optional: include this index rename (DROP + CREATE) in the deploy script' - : 'Include this index change in the deploy script'} - className="w-3.5 h-3.5 accent-cyan-500 cursor-pointer shrink-0" - /> - )} - {highlightMatch(idx.name, query)} - - {info?.columns.join(', ')}{info?.unique ? 'UNIQUE' : 'NON-UNIQUE'}{opBadge}
-
-
- )} - - {/* Foreign Keys Diff Section */} - {fkDiffs.length > 0 && ( -
-

- Foreign Key Relations -

-
- - - - - - - - - - - {fkDiffs.map((fk) => { - const info = fk.source || fk.target; - let opBadge = No Change; - if (fk.status === 'ADDED') { - opBadge = ADD CONSTRAINT; - } else if (fk.status === 'REMOVED') { - opBadge = DROP CONSTRAINT; - } - - return ( - - - - - - - ); - })} - -
Constraint NameColumnsReferences TableOperation
{highlightMatch(fk.name, query)}{info?.columns.join(', ')}{info?.referencedTable} ({(info?.referencedColumns ?? []).join(', ')}){opBadge}
-
-
- )} - - {/* Triggers Diff Section — always visible for tables */} - {selectedTable.objectType === 'TABLE' && ( -
-

- Table Triggers -

-
- - - - - - - - - - - - {trgDiffs.length === 0 ? ( - - - - ) : ( - trgDiffs.map((trg) => { - let opBadge = No Change; - let rowBg = 'hover:bg-slate-900/10'; - if (trg.status === 'ADDED') { - opBadge = CREATE TRIGGER; - rowBg = 'bg-emerald-950/10 hover:bg-emerald-950/20'; - } else if (trg.status === 'REMOVED') { - opBadge = DROP TRIGGER; - rowBg = 'bg-rose-950/10 hover:bg-rose-950/20'; - } else if (trg.status === 'MODIFIED') { - opBadge = RECREATE; - rowBg = 'bg-amber-950/10 hover:bg-amber-950/20'; - } - - const stateLabel = (info?: { timing?: string; event?: string }) => - info ? `${info.timing ?? ''} ${info.event ?? ''}`.trim() || 'present' : null; - - const isExpanded = !!expandedTriggers[trg.name]; - const { oldDdl = '', newDdl = '' } = formattedTriggerDdls[trg.name] ?? {}; - // A one-sided trigger diffs against '' — drop the resulting blank line - const ddlLines = isExpanded - ? diffLines(oldDdl, newDdl, { ignoreCase }).filter((l) => !(l.text === '' && (oldDdl === '' || newDdl === ''))) - : []; - - return ( - - toggleTriggerDdl(trg.name)} - title="Click to show DDL diff" - className={`${rowBg} transition-colors cursor-pointer`} - > - - - - - - - - {/* Expanded DDL diff for this trigger */} - {isExpanded && ( - - - - )} - - ); - }) - )} - -
Trigger NameOriginal StateCompareTarget StateOperation
- No triggers defined on this table -
- - {isExpanded - ? - : } - {highlightMatch(trg.name, query)} - - - {stateLabel(trg.source) ?? none} - - - - {stateLabel(trg.target) ?? none} - {opBadge}
- {trg.source?.definition || trg.target?.definition ? ( -
- - - {ddlLines.map((line, i) => { - const textClass = - line.type === 'added' - ? 'text-emerald-300' - : line.type === 'removed' - ? 'text-rose-300' - : 'text-slate-300'; - const lineBg = - line.type === 'added' - ? 'bg-emerald-500/10' - : line.type === 'removed' - ? 'bg-rose-500/10' - : ''; - const marker = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '; - return ( - - - - - ); - })} - -
{marker}{line.text}
-
- ) : ( -
- No DDL definition available for this trigger -
- )} -
-
-
- )} + />
); }; @@ -1318,43 +509,13 @@ export const ObjectDetailPanel: React.FC = () => {
{/* Detail Panel Toolbar */}
-
- - {/* DDL Diff and Migration SQL are comparison-only — hidden when browsing one schema. */} - {!browseMode && ( - <> - - - - )} -
+