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
23 changes: 23 additions & 0 deletions apps/web/src/backend/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,29 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt

const userId = (req as AuthedRequest).userId!;
const databaseId = String(req.params.id);
// History is keyed by database identity; the execute connection must be
// that same database or we would apply reverse DDL to the wrong target.
const identityMatch = await lokeeWeave.matchDatabaseIdentity(userId, databaseId, {
dialect,
host: option.host ?? null,
port: option.port ?? null,
database: option.database ?? null,
schema: schema ?? null,
});
if (identityMatch === 'not_found') {
res.status(404).json({ error: 'Database not found' });
return;
}
if (identityMatch === 'mismatch') {
res.status(409).json({
ok: false,
error:
'The selected connection does not match this schema history. Choose the credential for the same database before reverting.',
code: 'connection_mismatch',
});
return;
}

const plan = await lokeeWeave.planRevert(userId, databaseId, toVersionId, dialect, schema);
if (!plan) {
res.status(404).json({ error: 'Version not found' });
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/backend/modules/lokee-weave.module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,32 @@ describe('inspectObject', () => {
});
});

describe('matchDatabaseIdentity', () => {
it('accepts the same identity the history was captured under', async () => {
const { weave } = await freshStore();
const captured = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' });
await expect(
weave.matchDatabaseIdentity(USER, captured.databaseId, IDENTITY)
).resolves.toBe('ok');
});

it('rejects a different database so revert cannot target the wrong connection', async () => {
const { weave } = await freshStore();
const captured = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' });
await expect(
weave.matchDatabaseIdentity(USER, captured.databaseId, {
...IDENTITY,
database: 'other_shop',
})
).resolves.toBe('mismatch');
});

it('returns not_found for an unknown or foreign history id', async () => {
const { weave } = await freshStore();
await expect(weave.matchDatabaseIdentity(USER, 'missing', IDENTITY)).resolves.toBe('not_found');
});
});

describe('planRevert', () => {
it('classifies dropping a later column as lossy and emits DROP COLUMN', async () => {
const { weave } = await freshStore();
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/backend/modules/lokee-weave.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,25 @@ export class LokeeWeaveStore {
return Boolean(row?.id);
}

/**
* Revert plans from `databaseId` history but executes on a caller-supplied
* connection. Refuse when that connection’s identity is not the same row —
* otherwise DDL from one history can run against another database.
*/
async matchDatabaseIdentity(
userId: string,
databaseId: string,
input: DatabaseIdentityInput
): Promise<'ok' | 'not_found' | 'mismatch'> {
const store = await this.store();
const row = await store.get<{ fingerprint: string }>(
'SELECT fingerprint FROM lokee_databases WHERE id = ? AND user_id = ?',
[databaseId, userId]
);
if (!row) return 'not_found';
return row.fingerprint === databaseIdentity(input, sha256) ? 'ok' : 'mismatch';
}

async listVersions(userId: string, databaseId: string, limit = 100): Promise<VersionSummary[]> {
const store = await this.store();
if (!(await this.assertOwned(store, userId, databaseId))) return [];
Expand Down
11 changes: 8 additions & 3 deletions apps/web/src/frontend/api/lokeeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,11 @@ export interface LokeeRevertPlan {
}

export class LokeeRevertError extends Error {
readonly code: 'blocked' | 'confirm_lossy' | 'failed';
readonly code: 'blocked' | 'confirm_lossy' | 'connection_mismatch' | 'failed';
readonly plan?: LokeeRevertPlan;
constructor(
message: string,
code: 'blocked' | 'confirm_lossy' | 'failed',
code: 'blocked' | 'confirm_lossy' | 'connection_mismatch' | 'failed',
plan?: LokeeRevertPlan
) {
super(message);
Expand Down Expand Up @@ -260,7 +260,12 @@ export async function executeLokeeRevert(
LokeeRevertPlan & { ok?: boolean; error?: string; code?: string; capture?: CaptureResult }
>(res);
if (res.ok) return { ...data, ok: true as const };
const code = data.code === 'blocked' || data.code === 'confirm_lossy' ? data.code : 'failed';
const code =
data.code === 'blocked' ||
data.code === 'confirm_lossy' ||
data.code === 'connection_mismatch'
? data.code
: 'failed';
throw new LokeeRevertError(
data.error || res.statusText || 'Revert failed',
code,
Expand Down
Loading