fix: replace node-persist with minimal in-repo file storage - #1125
fix: replace node-persist with minimal in-repo file storage#1125tim-fin wants to merge 8 commits into
Conversation
Implements the scope agreed in homebridge#1107: HAPFileStorage keeps node-persist 0.0.12's on-disk layout byte-for-byte and its four synchronous operations (initSync, getItem, setItemSync, removeItemSync); everything else from the removed node-persist API throws with a clear error. Drops the deprecated q dependency along with node-persist, mkdirp@0.5 and the node-persist build/type workarounds. Claude-Session: https://claude.ai/code/session_01DpHuagGzRQVzatVmNiWa7p
There was a problem hiding this comment.
Pull request overview
This PR replaces the deprecated node-persist@0.0.12 dependency with a minimal in-repo synchronous file storage implementation (HAPFileStorage) that preserves the on-disk format and the small API surface HAP-NodeJS relies on, while simplifying the dependency graph and test setup.
Changes:
- Introduces
HAPFileStorage(init/load in-memory cache +getItem/setItemSync/removeItemSync) and switchesHAPStorageto use it. - Removes
node-persist(and related types, mocks, and build workaround) from dependencies and the codebase. - Updates tests to exercise real persistence via a Jest temp directory setup and adds dedicated
HAPFileStorageunit tests.
Reviewed changes
Copilot reviewed 14 out of 16 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| typedoc.config.mjs | Stops treating LocalStorage as intentionally unexported now that node-persist is removed. |
| src/types/node-persist.d.ts | Removes the hand-written node-persist type shim. |
| src/lib/model/IdentifierCache.spec.ts | Updates tests to assert persisted data via real storage instead of mocked LocalStorage. |
| src/lib/model/HAPStorage.ts | Switches singleton storage from node-persist to HAPFileStorage. |
| src/lib/model/HAPStorage.spec.ts | Updates storage tests to validate real filesystem behavior via temp dirs. |
| src/lib/model/HAPFileStorage.ts | Adds the new minimal storage implementation and runtime stubs for removed methods. |
| src/lib/model/HAPFileStorage.spec.ts | Adds focused unit tests covering layout compatibility and supported/unsupported operations. |
| src/lib/model/AccessoryInfo.ts | Updates a comment to avoid referencing node-persist directly. |
| src/lib/model/AccessoryInfo.spec.ts | Updates test to seed storage via setItemSync instead of mocking getItem. |
| src/index.ts | Exports HAPFileStorage from the public entrypoint. |
| package.json | Removes node-persist dependency and drops the build workaround script step. |
| package-lock.json | Removes node-persist, q, and mkdirp@0.5.x from the lockfile. |
| jest.setup.ts | Adds per-test-file temp storage redirection for the HAPStorage singleton. |
| jest.config.ts | Registers jest.setup.ts via setupFilesAfterEnv. |
| .github/node-persist-ignore.js | Deletes the now-unneeded build workaround script. |
| mocks/node-persist.ts | Deletes the Jest automock for node-persist. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Use an optional catch binding for the unused error variable and clarify the dir option documentation (absolute if provided, relative default). Claude-Session: https://claude.ai/code/session_01DpHuagGzRQVzatVmNiWa7p
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 16 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
src/lib/model/HAPFileStorage.ts:118
setItemSync/removeItemSynccurrently treat the key as a path (path.join(dir, key)) and evenmkdirSync(path.dirname(file)). That allows path traversal (e.g. key = "../x") and can create nested subdirectories; on the nextinitSync()those subdirectories will makereadFileSyncthrow unless handled. To keep the stated "one file per key inside the storage directory" invariant (and avoid writing outsidedir), validate keys are simple filenames and avoid creating per-key directories.
const file = path.join(this.dir, key);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(value));
src/lib/model/HAPFileStorage.ts:92
initSync()callsreadFileSyncon every non-dot entry fromreaddirSync(dir). If the storage directory contains a subdirectory (or other non-file), this will throw (e.g. EISDIR) and prevent storage initialization. Skipping non-regular files keeps init resilient and avoids a state where a key containing a path separator breaks future loads.
This issue also appears on line 116 of the same file.
for (const filename of fs.readdirSync(this.dir)) {
if (filename.startsWith(".")) {
continue;
}
|
Sorry for the slow reply on this one — it deserved a proper look rather than a skim. I went through it fairly carefully, and rather than just trusting the write-up I re-ran a lot of it myself: the full test suite, plus a side-by-side comparison against the real node-persist covering good files, truncated ones, empty ones, dotfiles, writes, deletes and a few odd values like One thing you've been too modest about. You mention that node-persist mishandled absolute paths with a trailing slash — what it actually did was dump the data inside On the three things you flagged for a veto: all fine by me. Relative paths can't reach us anyway — Homebridge always resolves the storage path to an absolute one before it gets that far — and I agree that failing loudly beats silently ignoring things. Two things I'd like changing before it goes in:
Nothing else in the code, as far as I can tell. Two other things, not code: This is a breaking change for any plugin using the bits you've removed, so it can't go out as a patch. It also isn't going into 2.1.10 — that release is already on the critical path for Homebridge 2.x going stable and I don't want to bolt a storage rewrite onto it. I'll aim it at 2.2.0. I'll also make sure the release notes spell out which methods have gone, so plugin authors can search for them rather than finding out the hard way. Last thing — the CI here hasn't actually run yet, it's waiting on approval because it's your first PR to this repo. I'll kick that off so we get a proper result across all the Node versions rather than just my laptop. Genuinely good work, and thanks for the detail in the description — the documentation on the class and making the removed methods fail with a clear message rather than just vanishing were both the right calls. |
Writes go to a temporary file which is renamed over the target, so an interrupted write can no longer truncate a file holding pairing data. Directories inside the storage directory are skipped when loading instead of aborting startup with EISDIR. Claude-Session: https://claude.ai/code/session_01DpHuagGzRQVzatVmNiWa7p
Asserts the temporary file is written beside its target, which is what keeps the replacing rename atomic.
|
Both changes are in, Atomic writes.
If the rename fails, the temporary file is removed and the original error rethrown, so a failed write doesn't leave litter in the user's persist folder. The final bytes on disk are unchanged, and a write onto an occupied path still fails with Directories on load. On the testing, since this is pairing data and "it passes" felt like a thin claim for a durability change:
Two limits worth putting on the record rather than leaving as surprises. Rename gives an atomic replace, but nothing is 2.2.0 and spelling the removed methods out in the release notes both make sense. If it saves you a step, I can paste the full list of the 36 names in a comment for you to lift. I've also updated the description: the trailing-separator/ |
| expect(fs.readdirSync(dir)).toEqual(["key.json"]); | ||
| }); | ||
|
|
||
| it("should create parent directories for a nested key, like node-persist 0.0.12", () => { |
There was a problem hiding this comment.
Hey!
One small thing on this test, and it's a question rather than a request.
I can see the real point of that test is the second assertion — that the temp file lands beside its target rather than in the storage root, since the rename wouldn't be atomic otherwise. That's well worth pinning and I'd keep it.
But the title says nested keys work "like node-persist 0.0.12", but with directories now skipped on load, a key like nested/key.json writes fine and then comes back undefined after a restart — the file is there, initSync just never looks in the subdirectory. node-persist actually crashed with EISDIR in that situation, so we've gone from a loud failure to a silent one, which for pairing data I'd rather not have.
Nothing in HAP uses keys with a separator, so this is theoretical today. But the test reads as though nested keys are supported, and someone may well build on that later.
Worth tightening? Two options I can see: reject a key containing a path separator, which fits the four-operations-and-fail-loudly approach you've taken elsewhere, or keep the test purely as the atomicity check and drop the nested-key claim from its name. I don't have a strong preference — you've been closer to this than I have.
Thanks!
There was a problem hiding this comment.
Confirmed, and it's worse than that test made it look — I reproduced it before changing anything. A key with a separator writes fine and then reads back undefined after a restart, exactly as you describe. path.join also means ../escaped.json writes outside the storage directory altogether; Copilot flagged that one at low confidence and I'd left it alone.
So I've taken your first option. setItemSync and removeItemSync now reject any key which isn't a plain filename — separator either way, ., .., empty. That closes both, and it turns the "one file per key inside the storage directory" line in the class doc into an invariant rather than an intention.
It is a fourth deliberate divergence, so it's in the description with the others for you to veto. Nothing in HAP-NodeJS can reach it — AccessoryInfo, IdentifierCache and ControllerStorage all build <Type>.<MAC>.json — so it's about what someone builds on the class later, which is the case you raised.
The assertion you wanted kept is still pinned, just no longer via nesting: the test spies on renameSync and asserts the temporary file and its target share a directory, which is the property that actually matters. I kept the mkdirSync before each write too — with nesting gone its only remaining job is recreating a storage directory removed at runtime, which 0.0.12 did through mkdirp.sync, so there's a test on that as well.
Storage class is at 46 tests, 864 in the suite, and I checked the new ones fail when the guard is taken back out.
Unrelated, from the run you kicked off: the single Coveralls regression was mine — HAPStorage.ts:30, the default-persist branch, which nothing reached once the node-persist automock was gone. 39c42e4d covers it and the file is back to 100%. Worth knowing that each push re-arms the first-PR approval gate, so the build is waiting on you again.
Coverage Report for CI Build 30512440035Coverage increased (+0.2%) to 66.405%Details
Uncovered ChangesNo uncovered changes found. Coverage Regressions1 previously-covered line in 1 file lost coverage.
Coverage Stats💛 - Coveralls |
Every existing HAPStorage test sets a custom storage path, and jest.setup.ts sets one on the singleton, so the branch which initialises into the default "persist" directory was no longer executed by any test.
A key was read as a path, so "nested/key.json" created a subdirectory and "../escaped.json" wrote outside the storage directory. Since initSync now skips directories, a nested key wrote successfully and then read back as undefined after a restart, where node-persist 0.0.12 at least failed loudly with EISDIR. Rejecting such keys makes the documented one-file-per-key layout an invariant. Nothing in HAP-NodeJS uses a key containing a separator.
|
Replying here rather than in the thread — the fix replaced the test your comment was anchored to, so GitHub has collapsed it as outdated. Confirmed, and it's worse than that test made it look — I reproduced it before changing anything. A key with a separator writes fine and then reads back So I've taken your first option. It is a fourth deliberate divergence, so it's in the description with the others for you to veto. Nothing in HAP-NodeJS can reach it — The assertion you wanted kept is still pinned, just no longer via nesting: the test spies on Storage class is at 46 tests, 864 in the suite, and I checked the new ones fail when the guard is taken back out. Unrelated, from the run you kicked off: the single Coveralls regression was mine — |
bwp91
left a comment
There was a problem hiding this comment.
Had another proper look through this, and re-ran the whole suite on your branch rather than going off the description — 864 passing, lint clean.
The concurrency measurement is the bit that stands out. 255 truncated reads out of 339 before, 0 out of 521 after, is exactly the kind of evidence I wanted and did not ask for. Checking your new tests actually fail when you back the behaviour out is also more than most PRs bother with, and it is the reason I am happy to trust the rest of it.
I have left three comments on the code. One of them I think genuinely matters, the other two are smaller.
The honest summary: the first one is the same bug you just fixed, reached a different way. Your key guard closes the nested/key.json door but leaves the one next to it open, and I only spotted it because you had just taught me what to look for.
Two things that are mine to decide, not yours:
fsync — you are right about what rename does and does not give us. I am going to say leave it. This is pairing data, but it is also every write, and the thing that was actually hurting people was corruption rather than losing the last write to a power cut. If someone reports the power-cut case we can revisit it with a real report behind it.
Windows EPERM — nothing to do here, but thank you for writing it down rather than letting us find out from an issue. I have made a note so it is not a surprise later.
And no need to paste the 36 names — UNSUPPORTED_METHODS in the source already is that list, so I will lift it straight from there for the release notes.
These are getting smaller each round and we are definitely getting there. Sort the key guard and I am happy; the other two are your call.
| * with `EISDIR` on the next load rather than losing the value quietly. | ||
| */ | ||
| private static assertPlainKey(key: string): void { | ||
| if (!key || key === "." || key === ".." || /[/\\]/.test(key)) { |
There was a problem hiding this comment.
This is the one I would like changed.
The guard stops /, \, ., .. and empty, but not a key that merely starts with a dot. And initSync above skips dotfiles on purpose, so:
storage.setItemSync(".hidden", { paired: true })
storage.getItem(".hidden") // { paired: true } - looks fine
// restart
storage.getItem(".hidden") // undefinedI ran it to be sure rather than reading it off the screen, and that is what happens — writes happily, comes back undefined after a restart.
Which is the exact thing you just fixed for nested/key.json, only arriving from a different direction. Same silent loss, same "looks like it worked" while it is running.
Same reasoning as before applies too: nothing in HAP-NodeJS can reach it, since everything here builds <Type>.<MAC>.json. So it is about whatever gets built on this class later, which is the case I raised in the first place.
Should just be adding a leading dot to what you already reject.
| if (fs.existsSync(this.dir)) { | ||
| for (const entry of fs.readdirSync(this.dir, { withFileTypes: true })) { | ||
| // directories are skipped: reading one would abort startup with a cryptic EISDIR | ||
| if (entry.name.startsWith(".") || entry.isDirectory()) { |
There was a problem hiding this comment.
Small one, and arguably too obscure to care about — your call entirely.
entry.isDirectory() does not follow symlinks, so a symlink pointing at a directory returns false here, gets past the skip, and lands in readFileSync below:
EISDIR: illegal operation on a directory, read
Which is the crash this line exists to prevent. I made one and confirmed it.
I would not raise it at all except you clearly thought about symlinks already — skipping only directories so a symlink to a real file still loads was the right call, and this is just the other half of the same thought. Something like entry.isFile() || (entry.isSymbolicLink() && fs.statSync(...).isFile()) keeps the behaviour you wanted.
Happy for you to wave this one off as not worth the code.
| setItemSync(key: string, value: any): void { | ||
| HAPFileStorage.assertPlainKey(key); | ||
|
|
||
| this.data.set(key, value); |
There was a problem hiding this comment.
This lands before anything touches the disk, so if the write or the rename throws, memory and disk disagree:
disk { v: 1 } <- correctly kept, your atomic write did its job
memory { v: 2 } <- the value that never actually got saved
Everything reads from memory, so the process carries on as if the write worked. For an AccessoryInfo that reads as paired now and unpaired after a restart, which is the confusing version of the problem you set out to fix rather than the loud one.
Your test covers the disk side and passes — it is only the in-memory half that is not pinned.
Moving this below renameSync should do it, so memory only ever holds what actually reached the disk.
Keys starting with a dot are now rejected. initSync skips dotfiles, so `.hidden` was written, served from memory while the process ran, and gone after a restart: the same silent loss as a key naming a subdirectory, reached from a different direction. initSync now skips anything which is not a regular file, resolving symlinks. A symlink pointing at a directory does not report as one to readdir, so it reached readFileSync and aborted startup with the EISDIR that check exists to prevent. A link to a file still loads. The in-memory value is only replaced once the write reached the disk. A failed write left memory holding a value which was not saved, and since everything is served from memory that reads as an accessory which is paired now and unpaired after a restart.
|
All three are in Leading dot. Reproduced it before changing anything, and it behaves exactly as you ran it: writes, serves from memory for as long as the process lives, gone after a restart. The guard now rejects any key starting with a dot, which subsumes the Symlink. Rather than adding a symlink branch to the dirent check, Memory ahead of disk. Storage class is at 52 tests, 870 in the suite, lint and typedoc clean. Each fix fails when the behaviour is backed out — one test for the dot, two each for the other two. Noted on |
Closes #1107 — implements the scope agreed there.
What
HAPFileStorage(~100 lines offs), implementing exactly the four operations HAP-NodeJS uses —initSync,getItem,setItemSync,removeItemSync— with the same synchronous semantics and the same on-disk layout asnode-persist@0.0.12: raw-key filenames, bareJSON.stringifycontents, values loaded once at init and served from memory afterwards.HAPStorage.storage()now returns it.AccessoryInfo,IdentifierCacheandControllerStorageare untouched — no call site changes shape.LocalStorageprototype (all 36 of them) is stubbed at runtime to throw with a message naming the method, the four supported operations, and a link to Update node-persist dependency to remove deprecated q package #1107 — so a plugin depending on more gets a clear error instead of undefined behaviour. TypeScript consumers get a compile error instead, since the stubs are deliberately absent from the class type.normalize(dir) !== resolve(dir), which misclassified absolute paths carrying a trailing separator and wrote that data intonode_modules/node-persist/src/storage/, where the nextnpm installwiped it. The new check ispath.isAbsolute(), so those paths now store where the caller asked.EISDIR.node-persist(and with itqandmkdirp@0.5.x), the hand-writtensrc/types/node-persist.d.ts, the__mocks__/node-persist.tsjest automock, and the.github/node-persist-ignore.jsbuild workaround, which only existed to paper over node-persist type collisions.Verification
node-persist@0.0.12and throughHAPFileStorage— directory listings and every file byte-identical, each implementation cleanly reads directories written by the other, identical dotfile and corrupt-JSON handling, identical reference/caching semantics (ControllerStorage.load()relies on mutating the objectgetItemreturns), identical error ordering on fs failures. Happy to attach the script if useful.jest.setup.ts, removed again inafterAll), so the suites that exercisepublish()(Accessory.spec,HAPServer.spec) persist for real instead of into an automock — they pass unchanged against the real implementation.HAPStorage.setCustomStoragePath(), always with an absolute path, so the main consumer sits comfortably inside the supported surface.Deliberate divergences, flagged for your veto
node_modules, where the data was lost on the nextnpm install;dir: undefinedcrashed with an unhelpfulTypeError. Neither seemed worth preserving, so both fail loudly with a clear message. One subtlety the other way: node-persist's absoluteness check (normalize !== resolve) also misclassified absolute paths carrying a trailing separator and buried those innode_modulestoo — the new check ispath.isAbsolute(), so such paths now work as the user intended. Absolute paths as Homebridge passes them behave exactly as before.ttl,interval, customstringify/parse, …) throw instead of being ignored. Supporting them would mean reimplementing most of node-persist; silently accepting-but-ignoring them seemed worse than refusing.getItem(key, callback)form throws. It did work before (0.0.12 invoked the callback synchronously in addition to returning the value), but supporting it would keep two calling conventions alive for one method; refusing loudly keeps the surface at exactly four operations, one form each. Easy to change to a 3-line compatibility shim if you'd rather keep it working.nested/key.jsoncreated a subdirectory and../escaped.jsonwrote outside the storage directory entirely. A key starting with a dot is refused for the same reason, since dotfiles are skipped on load. Each of those would have written successfully and then come backundefinedafter a restart — a silent loss, where 0.0.12 at least crashed withEISDIRon the nested case. Nothing in HAP-NodeJS can produce such a key (AccessoryInfo,IdentifierCacheandControllerStorageall build<Type>.<MAC>.json), so this is unreachable in practice today; rejecting it keeps the layout guarantee honest for anyone building on the class later.Writes are atomic, which node-persist's were not: each value is written to a temporary file which is then renamed over the target, so an interrupted write leaves the previous file intact instead of a truncated one — a truncated
AccessoryInforeads back as an accessory that has lost its pairings. The temporary file is named as a dotfile so a leftover from a crash is never loaded as a key, and carries the pid so processes sharing a storage directory (child bridges) can't race on it. The in-memory value is replaced only once the write has reached the disk, so a failed write cannot leave memory serving a value which would be gone after a restart.Naming (
HAPFileStorage), export placement, and anything else are of course yours to bikeshed — happy to adjust.