From 97ce4ef1984b725fc1e9754b5e788f9f5ec6cbec Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:46:22 -0400 Subject: [PATCH] fix(storage): treat a partial or malformed pluginStorage record as absent A data.json whose pluginStorage had no migration object, e.g. {"storageVersion": 2, "sourceOfTruthLocation": "vault-root"}, made buildRuntimeState() throw on persistedState.migration.state. The storage plan never resolved, so the adapter never became ready and every save (workspaces, workflows, schedules) failed with "Storage is not ready". PluginScopedStorageCoordinator now reads the persisted record through readPersistedStorageState(), which returns it only when migration is an object with a known state. Anything else is treated like an absent record: legacy data is re-detected on disk (pending when found, not_needed otherwise) and a fully formed record is written back. Only a well-formed verified record skips detection, so bad data cannot suppress a migration. persistMigrationState() and writeCacheBackendState() use the same reader. Co-Authored-By: Claude Opus 5.5 --- docs/changelog.md | 8 + .../PluginScopedStorageCoordinator.ts | 28 ++- ...dStorageCoordinator.malformedState.test.ts | 229 ++++++++++++++++++ 3 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 tests/unit/PluginScopedStorageCoordinator.malformedState.test.ts diff --git a/docs/changelog.md b/docs/changelog.md index 8c25e2b8f..234a0f656 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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)). diff --git a/src/database/migration/PluginScopedStorageCoordinator.ts b/src/database/migration/PluginScopedStorageCoordinator.ts index a5d70ed64..782065510 100644 --- a/src/database/migration/PluginScopedStorageCoordinator.ts +++ b/src/database/migration/PluginScopedStorageCoordinator.ts @@ -54,6 +54,28 @@ function isRecord(value: unknown): value is Record { 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))); } @@ -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) { @@ -169,7 +191,7 @@ export class PluginScopedStorageCoordinator { ): Promise { const pluginData = await this.loadPluginData(); const nextState = this.buildPersistedState( - pluginData.pluginStorage, + readPersistedStorageState(pluginData.pluginStorage), plan.vaultWriteBasePath, plan.state.migration.legacySourcesDetected, migrationState, @@ -196,7 +218,7 @@ export class PluginScopedStorageCoordinator { async writeCacheBackendState(state: CacheBackendState): Promise { 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); }); diff --git a/tests/unit/PluginScopedStorageCoordinator.malformedState.test.ts b/tests/unit/PluginScopedStorageCoordinator.malformedState.test.ts new file mode 100644 index 000000000..d3a00b970 --- /dev/null +++ b/tests/unit/PluginScopedStorageCoordinator.malformedState.test.ts @@ -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; + +function createCoordinator( + initialData: unknown, + initialFiles: Record = {} +) { + 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'); + }); +});