Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ CHANGELOG.md
# Public project docs must be tracked
!README.md
!CONTRIBUTING.md
# Licence notices for bundled dependencies must ship with the product —
# MIT/BSD/ISC all require their notice to travel with the code.
!THIRD-PARTY-NOTICES.md
# Agent onboarding notes must be tracked
!AGENTS.md
!docs/ARCHITECTURE.md
Expand Down
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages ./packages
COPY --from=build /app/apps/web ./apps/web
COPY --from=build /app/package.json ./package.json
# The licences of the packages baked into this image travel with it — MIT,
# BSD and ISC all require their notice to accompany the code.
COPY --from=build /app/LICENSE /app/NOTICE /app/THIRD-PARTY-NOTICES.md ./

# Persistent volume for the SQLite metadata store (saved connections, history)
# and the auto-generated encryption key (/data/.app_encryption_key).
Expand Down
7 changes: 7 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ to the Fox Schema project (https://github.com/tedious-code/foxschema).
Licensed under the Apache License, Version 2.0.
See the LICENSE file in the project root for full license text.

Third-party software
--------------------
Fox Schema is distributed with open-source packages whose licences require
their notices to travel with the code. Those notices are reproduced in
THIRD-PARTY-NOTICES.md in the project root, regenerated with
`node scripts/generate-third-party-notices.mjs`.

Attribution notice
------------------
Redistribution or reuse of this work must retain copyright notices and
Expand Down
1,697 changes: 1,697 additions & 0 deletions THIRD-PARTY-NOTICES.md

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions apps/e2e/src/pages/ConnectionModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import { fillInput } from '../helpers/driver.js';

export interface ConnectionFields {
dialect: string;
host: string;
port: number;
/** Omitted for file dialects — SQLite and DuckDB render no host / port. */
host?: string;
port?: number;
database: string;
username: string;
password: string;
username?: string;
password?: string;
schema?: string;
}

Expand Down
29 changes: 13 additions & 16 deletions apps/e2e/src/pages/SqlEditorPage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Page } from 'playwright';
import { clickWhen, waitFor, fillInput } from '../helpers/driver.js';
import type { DbConfig } from '../helpers/db-config.js';
import { ConnectionModal } from './ConnectionModal.js';
import { ConnectionModal, type ConnectionFields } from './ConnectionModal.js';

/**
* Page object for the SQL Editor workspace (view switcher + run against
Expand Down Expand Up @@ -60,36 +60,33 @@ export class SqlEditorPage {
await this.page.waitForSelector('[data-testid="cred-manager"]', { state: 'detached', timeout: 10_000 });
}

/** Save a SQLite file path as a named credential (password saved so Run/schema don't re-prompt). */
/** Save a SQLite file path as a named credential. */
async addSqliteCredential(name: string, dbPath: string): Promise<void> {
await this.addCredential(name, {
dialect: 'sqlite',
host: 'localhost',
port: 0,
database: dbPath,
username: 'unused',
password: 'unused',
});
// No host / port / user / password: SQLite is a file, and the form stops
// pretending otherwise — those boxes are not rendered for a file dialect.
await this.addCredential(name, { dialect: 'sqlite', database: dbPath });
}

/**
* Save any dialect credential from Credentials → Add.
* Fills the connection name, then reuses ConnectionModal.connect() (load schemas + save password).
*/
async addCredential(name: string, cfg: DbConfig): Promise<void> {
async addCredential(name: string, cfg: ConnectionFields | DbConfig): Promise<void> {
await this.openCredentials();
await clickWhen(this.page, '[data-testid="cred-add-btn"]');
await waitFor(this.page, '[data-testid="conn-modal"]');
const modal = new ConnectionModal(this.page);
// Dialect first — switching dialect can reset the form and wipe the name.
await modal.selectDialect(cfg.dialect);
await fillInput(this.page, '[data-testid="conn-name-input"]', name);
await modal.fillHost(cfg.host);
await modal.fillPort(cfg.port);
// A file dialect renders the path and nothing else — filling a hidden host
// box is what broke this helper when the form stopped showing one.
if (cfg.host !== undefined) await modal.fillHost(cfg.host);
if (cfg.port !== undefined) await modal.fillPort(cfg.port);
await modal.fillDatabase(cfg.database);
await modal.fillUsername(cfg.username);
await modal.fillPassword(cfg.password);
await modal.checkSavePassword();
if (cfg.username !== undefined) await modal.fillUsername(cfg.username);
if (cfg.password !== undefined) await modal.fillPassword(cfg.password);
if (cfg.password !== undefined) await modal.checkSavePassword();
// loadSchemas throws on conn-test-failed — never persist a bad credential.
await modal.loadSchemas();
if (cfg.schema) await modal.selectSchema(cfg.schema);
Expand Down
12 changes: 9 additions & 3 deletions apps/e2e/src/tests/schema-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,16 @@ describe.skipIf(!ready)('Schema Sync · History (SQLite)', () => {
expect(await driver.locator('[data-testid="lokee-rf-changes-only"]').isChecked()).toBe(true);
});

it('exposes view / procedure / function type filters', async () => {
it('offers the object types this schema actually has, and not the rest', async () => {
// The seed is a SQLite database with a table, an index, a view and a
// trigger. Procedure / Function / MQT filters could never match anything
// here, and MQT is a term only Db2 uses — a filter that cannot change what
// you see is noise in front of the ones that can.
expect(await history.typeFilterVisible('view')).toBe(true);
expect(await history.typeFilterVisible('procedure')).toBe(true);
expect(await history.typeFilterVisible('function')).toBe(true);
expect(await history.typeFilterVisible('index')).toBe(true);
expect(await history.typeFilterVisible('trigger')).toBe(true);
expect(await history.typeFilterVisible('mqt')).toBe(false);
expect(await history.typeFilterVisible('procedure')).toBe(false);
await history.enableType('view');
await driver.locator('[data-testid^="rf-object-"]').filter({ hasText: 'v_customers' }).first().waitFor({
timeout: 10_000,
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/backend/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,44 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt
return;
}

/**
* Snapshot the live schema before touching it.
*
* Two reasons, and the second is the one that matters. It leaves a
* version to come back to — but it also makes the plan *correct*:
* `planRevert` reverses from the newest **captured** version, not from
* what is actually in the database. If someone changed the schema by
* hand since the last capture, the reverse DDL was being computed against
* a picture that no longer existed.
*
* So when this snapshot finds drift, the request is refused rather than
* applied. The caller reviewed a plan built on the old head; running a
* different one silently is exactly the surprise this is here to stop.
*/
let preSnapshot: Awaited<ReturnType<typeof captureLiveSchema>>;
try {
preSnapshot = await captureLiveSchema(userId, { dialect, option, schema }, 'manual');
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'snapshot failed';
res.status(500).json({
ok: false,
error: `Could not snapshot the schema before reverting: ${message}`,
code: 'failed',
});
return;
}
if (preSnapshot.changed) {
res.status(409).json({
ok: false,
code: 'schema_drifted',
error:
`The live schema had changed since the last capture — snapshotted it as v${preSnapshot.versionNumber} ` +
`(${preSnapshot.changeCount} object change(s)). Review the diff against that version and run the revert again.`,
capture: preSnapshot,
});
return;
}

const objectKeys = Array.isArray(body.objectKeys)
? body.objectKeys.map((k) => String(k).trim()).filter(Boolean)
: undefined;
Expand Down
19 changes: 17 additions & 2 deletions apps/web/src/backend/modules/lokee-weave.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -924,7 +924,11 @@ export class LokeeWeaveStore {
versionId: version.id,
objectKey: key,
name: fallbackName(key),
objectType: (row.object_type as LokeeObjectType) ?? 'table',
// The key already carries the kind — `column:ORDERS.NOTE` is a
// column. Falling back to 'table' drew a deleted column with a
// table's icon and colour, and the card showed the raw compare key
// because the display split trusted that wrong type.
objectType: typeFromRowOrKey(row.object_type, key),
objectHash: null,
status: 'deleted',
});
Expand All @@ -947,7 +951,7 @@ export class LokeeWeaveStore {
versionId: version.id,
objectKey: key,
name: info?.name ?? fallbackName(key),
objectType: (info?.type as LokeeObjectType) ?? (row?.object_type as LokeeObjectType) ?? 'table',
objectType: (info?.type as LokeeObjectType) ?? typeFromRowOrKey(row?.object_type, key),
objectHash: hash,
status:
row?.operation === 'ADD' ? 'added' : row?.operation === 'MODIFY' ? 'modified' : 'unchanged',
Expand Down Expand Up @@ -1554,6 +1558,17 @@ function bodyFromRow(row: { body_json: string; shape_json?: string | null }): Re
}

/** `column:CUSTOMER.EMAIL` → `CUSTOMER.EMAIL`, for rows with no stored name. */
/**
* The object's kind, preferring what the delta row recorded and falling back to
* the key rather than to a guess. `table` was the old fallback, which is only
* right for containers — every deleted column came back typed as a table.
*/
function typeFromRowOrKey(rowType: string | null | undefined, key: string): LokeeObjectType {
if (rowType) return rowType as LokeeObjectType;
const kind = objectKeyKind(key);
return (kind || 'table') as LokeeObjectType;
}

function fallbackName(objectKey: string): string {
const colon = objectKey.indexOf(':');
return colon >= 0 ? objectKey.slice(colon + 1) : objectKey;
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/frontend/api/lokeeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,8 @@ export async function executeLokeeRevert(
const code =
data.code === 'blocked' ||
data.code === 'confirm_lossy' ||
data.code === 'connection_mismatch'
data.code === 'connection_mismatch' ||
data.code === 'schema_drifted'
? data.code
: 'failed';
throw new LokeeRevertError(
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/frontend/components/TopToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export const TopToolbar: React.FC = () => {
title: result.changed ? `Snapshot v${result.versionNumber}` : `No changes since v${result.versionNumber}`,
body: result.changed
? `${result.changeCount} object change(s) · ${result.objectCount} objects`
: 'Target schema matches the last snapshot (hash pointer reused).',
: 'Target schema matches the last snapshot — nothing new to record.',
});
setSyncPane('history');
} catch (err) {
Expand Down Expand Up @@ -266,7 +266,7 @@ export const TopToolbar: React.FC = () => {
type="button"
data-testid="sync-pane-history-btn"
onClick={() => setSyncPane('history')}
title="Content-addressed schema history (Lokee). Auto-snapshots on migrate."
title="Every version of this schema, and what changed between them. Snapshots automatically when you migrate."
className={`rounded px-2.5 py-1 text-xs font-semibold transition ${
syncPane === 'history'
? 'bg-violet-700/80 text-violet-50 ring-1 ring-violet-400/40'
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/frontend/components/lokee-weave/HistoryCompareBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,13 @@ export function HistoryCompareBar(): React.ReactElement {
{newestFirst.length === 0 && <option value="">No versions</option>}
{newestFirst.map((v) => (
<option key={v.id} value={v.id}>
{historyVersionLabel(v)}
{/* The newest version stays selectable — it is a perfectly good
baseline to compare against — but it says outright that a
revert onto it has nothing to do. */}
{historyVersionLabel(
v,
v.id === resolved.latest?.id ? { compareOnly: 'is-current' } : undefined
)}
</option>
))}
</select>
Expand Down Expand Up @@ -129,9 +135,13 @@ export function HistoryCompareBar(): React.ReactElement {
) : (
<option value="">Current database</option>
)}
{/* A revert restores the live database, so anything other than
"Current database" here is a comparison only — the diff would not
be the DDL that runs. Said in the option rather than discovered at
a greyed-out Execute button. */}
{olderTargets.map((v) => (
<option key={v.id} value={v.id}>
{historyVersionLabel(v)}
{historyVersionLabel(v, { compareOnly: 'not-current' })}
</option>
))}
</select>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ export function LokeeObjectInspector({
className={`mr-1 inline-block h-1.5 w-1.5 rounded-full align-middle ${
g.changed ? 'bg-amber-400' : 'bg-slate-700'
}`}
title={g.changed ? 'Changed in this version' : 'Unchanged (reused)'}
title={g.changed ? 'Changed in this version' : 'Unchanged in this version'}
aria-hidden
/>
<span className="font-semibold text-slate-200">v{g.versionNumber}</span>
Expand Down
39 changes: 22 additions & 17 deletions apps/web/src/frontend/components/lokee-weave/LokeeWeavePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { AlertTriangle, GripVertical, Lock, Unlock } from 'lucide-react';
import type { LokeeObjectType } from '@foxschema/sql';
import { OBJECT_STYLES, STATUS_STYLES, objectStyle, statusStyle } from '../../lib/lokeeColors';
import { LOKEE_NODE_TYPES } from './nodes';
import { buildVersionGraph, carryMeasurements } from './buildGraph';
import { buildVersionGraph, carryMeasurements, offeredObjectTypes } from './buildGraph';
import { useUiStore } from '../../store/uiStore';
import { VersionCompareModal } from './VersionCompareModal';
import {
Expand Down Expand Up @@ -83,16 +83,6 @@ export interface LokeeWeavePageProps {
embedded?: boolean;
}

const FILTERABLE_TYPES: LokeeObjectType[] = [
'table',
'view',
'mqt',
'index',
'column',
'trigger',
'function',
'procedure',
];
const STATUSES: GraphChangeStatus[] = ['added', 'modified', 'unchanged', 'deleted'];

/** When a schema has this many distinct objects, default to tables-only. */
Expand Down Expand Up @@ -505,7 +495,7 @@ export const LokeeWeavePage: React.FC<LokeeWeavePageProps> = ({
return (
<SidebarSection id="objectType" title="Object type" {...drag}>
<div className="flex flex-col gap-1">
{FILTERABLE_TYPES.map((t) => (
{offeredTypes.map((t) => (
<label
key={t}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-slate-300 hover:bg-slate-800/60"
Expand Down Expand Up @@ -661,8 +651,15 @@ export const LokeeWeavePage: React.FC<LokeeWeavePageProps> = ({
}
};

const offeredTypes = useMemo(
() => offeredObjectTypes(dto.objects, filters.objectTypes),
[dto.objects, filters.objectTypes]
);

const changed = dto.objects.filter((o) => o.status !== 'unchanged').length;
const reused = dto.objects.length - changed;
// "reused" is how the store thinks — one object pointed at by many versions.
// A reader of the history is asking what moved and what did not.
const unchanged = dto.objects.length - changed;

return (
<div data-testid="lokee-weave-page" className="flex h-full min-h-0 flex-col gap-2 overflow-hidden px-6 pt-2">
Expand All @@ -674,7 +671,7 @@ export const LokeeWeavePage: React.FC<LokeeWeavePageProps> = ({
data-testid="lokee-summary"
>
<span className="font-semibold text-slate-300">
{embedded ? 'Schema history' : 'Lokee Weave'}
Schema history
</span>
<span className="text-slate-600">·</span>
<span>
Expand All @@ -684,7 +681,7 @@ export const LokeeWeavePage: React.FC<LokeeWeavePageProps> = ({
<span className="font-bold text-slate-100">{dto.totalObjects}</span> objects
</span>
<span className="text-slate-500">
{changed} changed · {reused} reused
{changed} changed · {unchanged} unchanged
</span>
{subtitle && <span className="truncate text-slate-600">{subtitle}</span>}
</header>
Expand Down Expand Up @@ -859,7 +856,15 @@ export const LokeeWeavePage: React.FC<LokeeWeavePageProps> = ({
maxZoom={1.75}
defaultViewport={{ x: 24, y: 24, zoom: 1 }}
style={{ width: '100%', height: '100%' }}
proOptions={{ hideAttribution: false }}
/**
* React Flow is plain MIT (see node_modules/@xyflow/react/LICENSE).
* Its only condition is that the copyright and permission notice
* travel with the software, which THIRD-PARTY-NOTICES.md at the
* repo root does — the on-canvas badge is not a licence term.
* xyflow asks that you subscribe to Pro when you hide it; that is
* a request, and the project has chosen not to.
*/
proOptions={{ hideAttribution: true }}
>
<FitReadableView nodes={built.nodes} />
<Background gap={20} />
Expand Down Expand Up @@ -900,7 +905,7 @@ export const LokeeWeavePage: React.FC<LokeeWeavePageProps> = ({
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-px w-6 border-t border-dashed border-sky-400" aria-hidden />{' '}
Reused from previous version
Unchanged from previous version
</span>
<span className="flex items-center gap-1.5">
<span
Expand Down
Loading
Loading