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
8 changes: 8 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## October 2026

**Unreleased** — Saving works again in vaults with an incomplete storage record

> Draft for the next release. Version number and PR links are added when it is cut.

- In a vault whose Nexus `data.json` had a storage record without its migration details (for example after the file was cleaned by hand), storage never finished starting. Every save failed with "Failed to save workspace" or "Storage is not ready", including workspaces, workflows and schedules. Nexus now treats an incomplete or damaged storage record as missing: it checks the vault for older Nexus data, moves it over if there is any, and rewrites the record. No data is deleted.

---

**v5.21.1** — API keys validate again, and retired models no longer block a provider

- Adding an OpenRouter API key failed with "No endpoints found for liquid/lfm-2.5-1.2b-thinking:free", so OpenRouter stayed turned off even with a working key. The check sent a test message to a free model that OpenRouter has since removed. It now asks OpenRouter whether the key is valid, which costs nothing and does not depend on any model ([#420](https://github.com/ProfSynapse/nexus/pull/420)).
Expand Down
28 changes: 25 additions & 3 deletions src/database/migration/PluginScopedStorageCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,28 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

const MIGRATION_STATES: readonly PluginScopedMigrationState[] = ['not_needed', 'pending', 'verified', 'failed'];

/**
* Return the persisted JSONL-migration record only when its `migration` block
* is usable. data.json is user-editable and can be synced or hand-cleaned, so
* `pluginStorage` may be missing, partial (no `migration`), or malformed
* (`migration: null`, missing or unknown `state`). Any such record is treated
* as absent: the caller re-detects legacy data on disk and writes a fully
* formed state back. Only a well-formed `verified` record is trusted to skip
* detection, so unusable data can never suppress a needed migration.
*/
function readPersistedStorageState(value: unknown): PluginScopedStorageState | undefined {
if (!isRecord(value) || !isRecord(value.migration)) {
return undefined;
}
const state = value.migration.state;
if (typeof state !== 'string' || !MIGRATION_STATES.includes(state as PluginScopedMigrationState)) {
return undefined;
}
return value as unknown as PluginScopedStorageState;
}

function buildUniquePaths(...basePaths: string[]): string[] {
return Array.from(new Set(basePaths.filter(path => typeof path === 'string' && path.trim().length > 0)));
}
Expand Down Expand Up @@ -115,7 +137,7 @@ export class PluginScopedStorageCoordinator {
legacySourcesDetected: string[],
vaultRootHasEventData: boolean
): PluginScopedStorageState {
const persistedState = pluginData.pluginStorage;
const persistedState = readPersistedStorageState(pluginData.pluginStorage);

if (persistedState?.migration.state === 'verified') {
if (!vaultRootHasEventData && legacySourcesDetected.length > 0) {
Expand Down Expand Up @@ -169,7 +191,7 @@ export class PluginScopedStorageCoordinator {
): Promise<PluginScopedStorageState> {
const pluginData = await this.loadPluginData();
const nextState = this.buildPersistedState(
pluginData.pluginStorage,
readPersistedStorageState(pluginData.pluginStorage),
plan.vaultWriteBasePath,
plan.state.migration.legacySourcesDetected,
migrationState,
Expand All @@ -196,7 +218,7 @@ export class PluginScopedStorageCoordinator {
async writeCacheBackendState(state: CacheBackendState): Promise<void> {
await pluginDataLock.acquire(async () => {
const data = await this.loadPluginData();
const existing = data.pluginStorage ?? this.createNotNeededState('');
const existing = readPersistedStorageState(data.pluginStorage) ?? this.createNotNeededState('');
data.pluginStorage = { ...existing, cacheBackend: state };
await this.plugin.saveData(data);
});
Expand Down
229 changes: 229 additions & 0 deletions tests/unit/PluginScopedStorageCoordinator.malformedState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/**
* Regression tests for persisted `pluginStorage` records that are missing,
* partial or malformed.
*
* Bug (5.21.1): a data.json whose `pluginStorage` existed but had no
* `migration` object, e.g.
* "pluginStorage": { "storageVersion": 2, "sourceOfTruthLocation": "vault-root" }
* made buildRuntimeState() throw on `persistedState.migration.state`. The
* storage plan never resolved, so storage never became ready and every save
* (workspaces, workflows, schedules) failed with "Storage is not ready".
*
* Expected: an unusable persisted record is treated exactly like an absent
* one — the coordinator re-detects legacy data on disk and returns a fully
* formed state (pending when legacy event data exists, not_needed otherwise).
*/

import { PluginScopedStorageCoordinator } from '../../src/database/migration/PluginScopedStorageCoordinator';
import { createMockApp } from '../helpers/mockVaultAdapter';

type PluginData = Record<string, unknown>;

function createCoordinator(
initialData: unknown,
initialFiles: Record<string, string> = {}
) {
const { app, adapter } = createMockApp({ configDir: '.obsidian', initialFiles });
let stored: unknown = initialData;
const saveData = jest.fn(async (next: PluginData) => {
stored = JSON.parse(JSON.stringify(next));
});
const loadData = jest.fn(async () => (stored === null || stored === undefined
? stored
: JSON.parse(JSON.stringify(stored))));
const coordinator = new PluginScopedStorageCoordinator(
app as never,
{
manifest: {
id: 'nexus',
dir: '/mock/.obsidian/plugins/nexus'
},
loadData,
saveData
} as never,
'.nexus'
);
return { coordinator, adapter, saveData, getStored: () => stored as PluginData };
}

const LEGACY_FILES = {
'.nexus/workspaces/ws_alpha.jsonl': '{"id":"legacy-evt"}\n'
};

describe('PluginScopedStorageCoordinator — missing, partial or malformed pluginStorage', () => {
it('initializes a not_needed state when pluginStorage is absent', async () => {
const { coordinator, getStored } = createCoordinator({});

const plan = await coordinator.prepareStoragePlan();

expect(plan.state.migration.state).toBe('not_needed');
expect(plan.state.sourceOfTruthLocation).toBe('vault-root');
expect(plan.state.migration.activeDestination).toBe('Nexus/data');
expect((getStored().pluginStorage as { migration: { state: string } }).migration.state).toBe('not_needed');
});

it('treats pluginStorage without a migration object as absent instead of throwing', async () => {
const { coordinator, getStored } = createCoordinator({
pluginStorage: { storageVersion: 2, sourceOfTruthLocation: 'vault-root' }
});

const plan = await coordinator.prepareStoragePlan();

expect(plan.state.storageVersion).toBe(2);
expect(plan.state.sourceOfTruthLocation).toBe('vault-root');
expect(plan.state.migration.state).toBe('not_needed');
expect(plan.state.migration.legacySourcesDetected).toEqual([]);
expect(plan.state.migration.activeDestination).toBe('Nexus/data');

// The repaired, fully formed record is written back to data.json.
const saved = getStored().pluginStorage as { migration: { state: string; activeDestination: string } };
expect(saved.migration.state).toBe('not_needed');
expect(saved.migration.activeDestination).toBe('Nexus/data');
});

it.each([
['null migration', { storageVersion: 2, sourceOfTruthLocation: 'vault-root', migration: null }],
['migration without a state', {
storageVersion: 2,
sourceOfTruthLocation: 'vault-root',
migration: { legacySourcesDetected: [], activeDestination: 'Nexus/data' }
}],
['migration with an unknown state', {
storageVersion: 2,
sourceOfTruthLocation: 'vault-root',
migration: { state: 'bogus' }
}],
['non-object migration', { storageVersion: 2, sourceOfTruthLocation: 'vault-root', migration: 'verified' }],
['non-object pluginStorage', 'vault-root'],
['null pluginStorage', null]
])('normalizes %s to a not_needed state when no legacy data exists', async (_label, pluginStorage) => {
const { coordinator, getStored } = createCoordinator({ pluginStorage });

const plan = await coordinator.prepareStoragePlan();

expect(plan.state.migration.state).toBe('not_needed');
expect(plan.state.sourceOfTruthLocation).toBe('vault-root');
expect(plan.state.migration.activeDestination).toBe('Nexus/data');
expect((getStored().pluginStorage as { migration: { state: string } }).migration.state).toBe('not_needed');
});

it('still migrates real legacy data when pluginStorage is partial', async () => {
const { coordinator } = createCoordinator(
{ pluginStorage: { storageVersion: 2, sourceOfTruthLocation: 'vault-root' } },
LEGACY_FILES
);

const plan = await coordinator.prepareStoragePlan();

expect(plan.state.migration.state).toBe('pending');
expect(plan.state.sourceOfTruthLocation).toBe('legacy-dotnexus');
expect(plan.state.migration.legacySourcesDetected).toEqual(['.nexus']);
expect(plan.legacyReadBasePaths).toContain('.nexus');
expect(typeof plan.state.migration.startedAt).toBe('number');

// The rest of the migration lifecycle works against the repaired record.
const verified = await coordinator.persistMigrationState(plan, 'verified', {
completedAt: 10,
verifiedAt: 20
});
expect(verified.migration.state).toBe('verified');
expect(verified.sourceOfTruthLocation).toBe('vault-root');
expect(verified.migration.startedAt).toBe(plan.state.migration.startedAt);
});

it('still migrates real legacy data when migration is null', async () => {
const { coordinator } = createCoordinator(
{ pluginStorage: { storageVersion: 2, sourceOfTruthLocation: 'vault-root', migration: null } },
LEGACY_FILES
);

const plan = await coordinator.prepareStoragePlan();

expect(plan.state.migration.state).toBe('pending');
expect(plan.state.migration.legacySourcesDetected).toEqual(['.nexus']);
});

it('persists a migration outcome when data.json still holds a partial record', async () => {
const { coordinator, getStored } = createCoordinator({}, LEGACY_FILES);
const plan = await coordinator.prepareStoragePlan();

// Simulate data.json being hand-edited back to a partial shape mid-session.
getStored().pluginStorage = { storageVersion: 2, sourceOfTruthLocation: 'vault-root' };

const failed = await coordinator.persistMigrationState(plan, 'failed', {
completedAt: 30,
lastError: 'boom'
});
expect(failed.migration.state).toBe('failed');
expect(failed.migration.lastError).toBe('boom');
expect(typeof failed.migration.startedAt).toBe('number');
});

it('writes a fully formed record when saving cache-backend state over a partial record', async () => {
const { coordinator, getStored } = createCoordinator({
pluginStorage: { storageVersion: 2, sourceOfTruthLocation: 'vault-root' }
});

await coordinator.writeCacheBackendState({ backend: 'idb', migrationState: 'verified', migratedAt: 1 });

const saved = getStored().pluginStorage as {
migration: { state: string };
cacheBackend: { backend: string };
};
expect(saved.migration.state).toBe('not_needed');
expect(saved.cacheBackend.backend).toBe('idb');
});

it('preserves cacheBackend from a partial record across the repair write', async () => {
const { coordinator, getStored } = createCoordinator({
pluginStorage: {
storageVersion: 2,
sourceOfTruthLocation: 'vault-root',
cacheBackend: { backend: 'idb', migrationState: 'verified', migratedAt: 42 }
}
});

await coordinator.prepareStoragePlan();

const saved = getStored().pluginStorage as {
migration: { state: string };
cacheBackend: { migratedAt: number };
};
expect(saved.migration.state).toBe('not_needed');
expect(saved.cacheBackend.migratedAt).toBe(42);
});

it('keeps the normal verified path unchanged', async () => {
const { coordinator, adapter } = createCoordinator(
{
pluginStorage: {
storageVersion: 2,
sourceOfTruthLocation: 'vault-root',
migration: {
state: 'verified',
startedAt: 111,
completedAt: 222,
verifiedAt: 333,
legacySourcesDetected: ['.nexus'],
activeDestination: 'Nexus/data'
}
}
},
{
...LEGACY_FILES,
'Nexus/data/workspaces/ws_alpha.jsonl': '{"id":"legacy-evt"}\n'
}
);
expect(await adapter.exists('Nexus/data/workspaces/ws_alpha.jsonl')).toBe(true);

const plan = await coordinator.prepareStoragePlan();

expect(plan.state.migration.state).toBe('verified');
expect(plan.state.sourceOfTruthLocation).toBe('vault-root');
expect(plan.state.migration.startedAt).toBe(111);
expect(plan.state.migration.completedAt).toBe(222);
expect(plan.state.migration.verifiedAt).toBe(333);
expect(plan.state.migration.legacySourcesDetected).toEqual(['.nexus']);
expect(plan.state.migration.activeDestination).toBe('Nexus/data');
});
});
Loading