Skip to content

Fix storage never becoming ready when pluginStorage has no migration record - #422

Merged
ProfSynapse merged 1 commit into
mainfrom
fix/plugin-storage-partial-migration
Oct 16, 2026
Merged

ProfSynapse merged 1 commit into
mainfrom
fix/plugin-storage-partial-migration

Conversation

@ProfSynapse

Copy link
Copy Markdown
Owner

Summary

In 5.21.1, a vault whose Nexus data.json has a pluginStorage object without a migration sub-object never gets storage ready. Every save fails with "Failed to save workspace" / "Storage is not ready, so this change was not saved": workspaces, workflows and schedules. The shape that triggers it:

"pluginStorage": { "storageVersion": 2, "sourceOfTruthLocation": "vault-root" }

It came from hand-cleaning a shipped vault's data.json (the course Seed Vault).

Root cause

PluginScopedStorageCoordinator.buildRuntimeState() read persistedState?.migration.state. The optional chain guards only persistedState, so when the record exists but migration is undefined or null, it throws Cannot read properties of undefined (reading 'state'). prepareStoragePlan() rejects, HybridStorageAdapter initialization fails, and every write is refused by the storage-not-ready guard. buildPersistedState() (baseState.migration.startedAt) had the same unguarded access.

Fix

  • New readPersistedStorageState() returns the persisted record only when migration is an object with a known state (not_needed | pending | verified | failed). Otherwise it returns undefined.
  • buildRuntimeState(), persistMigrationState() and writeCacheBackendState() read through it. A missing, partial or malformed record is handled exactly like an absent one, which is the path a fresh install takes (and the path the known workaround, deleting pluginStorage, takes):
    • legacy event data on disk → pending, and the normal backfill migrates it. The backfill only appends events missing from vault-root and dedups by event id, so a re-run is non-destructive;
    • no legacy data → not_needed;
    • either way a fully formed record is written back to data.json, and an existing cacheBackend record is preserved.
  • Only a well-formed verified record skips detection, so unusable data can never suppress a needed migration.
  • The normal verified, pending and failed paths are unchanged. plan.state is always built by the coordinator, so downstream readers (HybridStorageAdapter) always get a complete shape.
  • docs/changelog.md gets an Unreleased draft entry, following the existing convention. No version bump.

Tests

New tests/unit/PluginScopedStorageCoordinator.malformedState.test.ts (14 tests). 8 of them failed on main with the production TypeError:

  • initializes a not_needed state when pluginStorage is absent
  • treats pluginStorage without a migration object as absent instead of throwing
  • normalizes %s to a not_needed state when no legacy data exists, for: null migration, migration without a state, migration with an unknown state, non-object migration, non-object pluginStorage, null pluginStorage
  • still migrates real legacy data when pluginStorage is partial, then persists verified
  • still migrates real legacy data when migration is null
  • persists a migration outcome when data.json still holds a partial record
  • writes a fully formed record when saving cache-backend state over a partial record
  • preserves cacheBackend from a partial record across the repair write
  • keeps the normal verified path unchanged

Test plan

  • New suite: 14/14 pass (8 failed before the fix)
  • Full Jest: 557 suites passed (19 skipped), 6,683 tests passed (95 skipped)
  • npm run lint clean; npm run build passes, including tsc --noEmit and the CLI and connector builds
  • check_live_lane_gates.py and check_catalog_target.py OK
  • Live in Obsidian (isolated instance, own profile, throwaway vault with pluginStorage lacking migration):
    • the 5.21.1 release main.js reproduced it: HybridStorageAdapter Initialization failed: TypeError: Cannot read properties of undefined (reading 'state') at buildRuntimeState, and DefaultWorkspaceManager failed with "Storage is not ready";
    • with this branch's build the adapter became ready and data.json was rewritten with migration.state: "not_needed";
    • the default workspace and a new workspace created through workspaceService.createWorkspace() were both saved to Nexus/data/workspaces/;
    • the scratch instance and vault were deleted afterwards.

Notes

  • The course Seed Vault was patched separately, by removing pluginStorage from its data.json. This PR makes Nexus tolerate that shape for anyone else who has it.
  • Not released: no version bump, tag or store publish. Which release it ships in is the maintainer's call.

🤖 Generated with Claude Code

…sent

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 <noreply@anthropic.com>
@ProfSynapse
ProfSynapse merged commit edc6900 into main Oct 16, 2026
1 check passed
@ProfSynapse
ProfSynapse deleted the fix/plugin-storage-partial-migration branch October 16, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant