# 用户目录、配置写入与本地数据可靠性

## 本地数据先回答“谁拥有它”

网页应用常把数据交给后端，Electron 让你第一次必须决定文件放在哪、谁能改、什么时候算保存成功。用户设置适合小型 JSON；可查询的业务记录可能适合 SQLite；大型附件通常单独存文件并由元数据引用。它们的共同点是不能写到应用安装目录，也不能依赖启动命令的当前目录。

本章的程度是能够实现单实例、小配置文件的可靠保存，并知道何时需要数据库和更强的恢复机制。前置是 Promise、文件路径与主进程职责；不要求掌握数据库内核。两个完整实验都使用 Node 标准库，可以离线运行；Electron 的目录接入另行说明。我们验证的是临时目录中的文件语义，没有把实验文件当作用户真实配置。

app.getPath("userData") 返回 Electron 为当前应用选择的用户数据目录。它不是“所有用户共享目录”，也不是安装包资源目录。默认位置与应用名称及操作系统有关，所以示例不硬编码 Windows 用户名或 macOS 路径。主进程在 app.whenReady 后取得它，再建立应用自己的数据子目录。改应用名称、产品标识或主动改路径，可能使旧数据看起来消失，迁移需要独立设计。[app 目录 API](https://www.electronjs.org/docs/latest/api/app)

renderer 不应接收一个绝对文件路径再请求“写进去”。界面应该发送“把主题改成深色”或者“保存这条记录”，由 main 决定固定存储位置、校验内容与执行写入。这样系统权限留在主进程，用户输入不会变成任意路径。文件选择功能可以由主进程创建受控句柄或业务标识，再限制用途，不把路径访问能力普遍暴露给页面。

## 三个容易混淆的保证

写队列解决同一进程内的操作顺序。两次保存同时开始时，如果各自先读旧值再写，新值可能被慢操作覆盖；即使每次文件写入都完整，最后的业务结果仍然错误。队列让读取当前状态、检查版本、生成下一个状态和提交形成一个顺序。把 await 加在调用者函数里不等于全局排队，另一个窗口仍可能同时发起调用。

临时文件加 rename 解决目标文件被直接截断后只写了一半的问题。完整新内容先写到同目录的唯一临时文件，关闭后替换目标；读取者通常看到旧文件或新文件，而不是半段 JSON。但这需要文件系统支持相应重命名语义，同目录很重要，跨文件系统移动不是同一种操作。Windows 文件占用、权限、杀毒软件和网络盘都可能让替换失败。

崩溃持久性又是另一层。writeFile 返回不代表断电后必然落到存储介质；对文件句柄 sync 可以请求刷新该文件，但重命名涉及目录元数据，严谨的掉电保证还与目录刷新、操作系统和文件系统有关。本章不声称跨平台电源故障下绝不丢最后一次提交。应明确允许的损失、是否保留上个版本，以及能否从日志或备份恢复。

还有一个业务保证叫并发冲突检测。两个窗口都从 revision=7 编辑，即便写队列按顺序执行，第二个窗口的旧快照也不应无声覆盖第一个窗口刚写的 revision=8。请求携带 expectedRevision，主进程只接受匹配当前版本的更新；冲突返回给 UI 重新读取或让用户合并。队列决定先后，版本比较决定旧编辑是否仍有资格提交。

## 实验一：有写队列和失败边界的配置仓库

创建 config-lab 目录，保存 settings-store.cjs 和 settings-check.cjs，使用 Node 22.22.0 执行 node settings-check.cjs，无需安装包。这个仓库只接受主题配置，不接收任意路径；路径由创建仓库的主进程提供。schemaVersion 表示文件格式，revision 表示内容更新次数，二者不能互相替代。

```javascript settings-store.cjs
const fs = require("node:fs/promises");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
function validate(value) {
  if (!value || value.schemaVersion !== 2 ||
      !Number.isSafeInteger(value.revision) || value.revision < 0 ||
      !["light", "dark"].includes(value.theme)) {
    throw new Error("INVALID_CONFIG");
  }
  return { schemaVersion: 2, revision: value.revision, theme: value.theme };
}
function createStore(file, { beforeCommit = async () => {} } = {}) {
  let state;
  let tail = (async () => {
    await fs.mkdir(path.dirname(file), { recursive: true });
    try {
      state = validate(JSON.parse(await fs.readFile(file, "utf8")));
    } catch (error) {
      if (error.code !== "ENOENT") throw error;
      state = { schemaVersion: 2, revision: 0, theme: "light" };
    }
  })();
  function queue(operation) {
    const result = tail.then(operation);
    // 后续任务仍有机会执行，但当前调用者仍收到自己的失败。
    tail = result.catch(() => {});
    return result;
  }
  return {
    read: () => queue(() => {
      if (!state) throw new Error("STORE_NOT_READY");
      return { ...state };
    }),
    update(expectedRevision, theme) {
      // 立即捕获原始标量参数，后续不读取调用者可变对象。
      return queue(async () => {
        if (!state) throw new Error("STORE_NOT_READY");
        if (expectedRevision !== state.revision) throw new Error("REVISION_CONFLICT");
        const next = validate({
          schemaVersion: 2, revision: state.revision + 1, theme
        });
        const temp = file + "." + randomUUID() + ".tmp";
        let created = false;
        try {
          const handle = await fs.open(temp, "wx", 0o600);
          created = true;
          try {
            await handle.writeFile(JSON.stringify(next) + "\n", "utf8");
            await handle.sync(); // 刷新文件内容；不夸大为完整断电持久性。
          } finally {
            await handle.close();
          }
          await beforeCommit(); // 仅供本地故障实验注入，不暴露给 IPC。
          await fs.rename(temp, file);
          state = next; // 文件替换成功后，才发布新的内存状态。
          return { ...state };
        } finally {
          if (created) await fs.rm(temp, { force: true });
        }
      });
    }
  };
}
module.exports = { createStore };
```

```javascript settings-check.cjs
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { createStore } = require("./settings-store.cjs");
(async () => {
  const tempBase = await fs.realpath(os.tmpdir());
  const tempPrefix = "settings-lab-";
  const root = await fs.mkdtemp(path.join(tempBase, tempPrefix));
  try {
    const file = path.join(root, "settings.json");
    const store = createStore(file);
    assert.deepEqual(await store.read(),
      { schemaVersion: 2, revision: 0, theme: "light" });
    const results = await Promise.allSettled([
      store.update(0, "dark"), store.update(0, "light")
    ]);
    assert.equal(results[0].status, "fulfilled");
    assert.equal(results[1].status, "rejected");
    assert.match(results[1].reason.message, /REVISION_CONFLICT/);
    const before = await fs.readFile(file, "utf8");
    const broken = createStore(file, {
      beforeCommit: async () => { throw new Error("INJECTED_FAILURE"); }
    });
    await assert.rejects(() => broken.update(1, "light"), /INJECTED_FAILURE/);
    assert.equal(await fs.readFile(file, "utf8"), before);
    assert.equal((await broken.read()).theme, "dark");
    assert.deepEqual((await fs.readdir(root)).sort(), ["settings.json"]);
    const reopened = createStore(file);
    assert.equal((await reopened.read()).revision, 1);
    console.log("settings checks passed");
  } finally {
    const resolved = await fs.realpath(root);
    if (path.dirname(resolved) !== tempBase || !path.basename(resolved).startsWith(tempPrefix)) {
      throw new Error("UNSAFE_TEMP_CLEANUP");
    }
    await fs.rm(resolved, { recursive: true, force: true });
  }
})().catch(error => { console.error(error); process.exitCode = 1; });
```

正常输出 settings checks passed。实验同时发出两个基于零版本的更新，第一个成功，第二个收到冲突；随后在 rename 前主动制造故障，磁盘和内存都必须维持上次成功状态。重新创建仓库对象模拟重新读取磁盘，不等于真实进程突然断电，但足以检查数据没有只存在于旧内存对象里。

fs.open 的 wx 表示必须新建文件，已有同名路径会失败；随机标识降低碰撞可能性，排他创建才是拒绝覆盖的规则。临时文件与目标同目录，所以替换不会跨卷。finally 清理的是本次成功创建的临时文件；rename 成功后临时路径已不存在，force:true 允许这种预期情况。目标文件从不在失败清理中删除。

read 返回浅拷贝之所以足够，是 schema 只有标量。添加嵌套配置后，应使用受支持的数据拷贝方式或不可变结构，不能让外部调用者拿到 state 内部引用。validate 明确生成允许字段，避免把用户提交的未知属性原样落盘。例子没有把 JSON.parse 错误视作“新用户默认配置”，因为那会悄悄掩盖损坏文件，下一次保存可能覆盖事故证据。

## 初始化失败不能被队列吞掉

这里要特别区分“单次更新失败后队列继续工作”和“初始配置加载失败后允许新建默认状态”。前者常常合理：某次主题值错误不应该让整个仓库永久瘫痪；后者危险：磁盘文件格式错误时，仓库没有可信初始状态。本例 update 检查 state，阻止在初始化失败后写入；read 同样检查 state，因此首次读取暴露原始加载错误后，后续读取仍会明确抛出 STORE_NOT_READY，不会成功返回空对象。

对权限拒绝、磁盘满、文件被占用，应该保留原错误的技术分类，同时向 UI 返回稳定错误码。UI 可以提示重试或导出未保存内容，日志记录操作编号、文件角色和错误码，不打印所有配置值。不要遇到任何错误就先删 settings.json 再重试：这会把短暂 I/O 问题变成永久数据损失。

同一进程队列不处理两个应用实例的竞争。桌面应用通常可用 app.requestSingleInstanceLock 限制主实例，但多用户、辅助进程或者外部编辑器仍可能写文件。要求多写者时，应使用有事务与锁语义的存储，或者设计操作系统锁与恢复策略。本例明确限定单个仓库所有者，不能把它包装成通用多进程数据库。

## 在 Electron 主进程中接入固定目录

下面是可独立作为 Electron 入口运行的 main.cjs，它没有创建窗口，只展示目录选择与读取。将 settings-store.cjs 放在同目录，使用前章固定 Electron 依赖，执行 electron main.cjs；需要 Electron 可启动的桌面环境，本文没有运行。首次没有配置时输出默认状态，随后退出；不会自动写用户文件。

```javascript main.cjs
const { app } = require("electron");
const path = require("node:path");
const { createStore } = require("./settings-store.cjs");
const ownsLock = app.requestSingleInstanceLock();
if (!ownsLock) {
  app.quit();
} else {
  app.whenReady().then(async () => {
    const file = path.join(app.getPath("userData"), "application-data", "settings.json");
    const store = createStore(file);
    const value = await store.read();
    console.log({ dataDirectory: path.dirname(file), config: value });
    app.quit();
  }).catch(error => {
    console.error({ code: error.code ?? "CONFIG_LOAD_FAILED", message: error.message });
    app.exit(1);
  });
}
```

application-data 子目录把自己管理的数据与 Chromium 会话缓存区分开。缓存损坏时重建缓存，不应该顺便删除业务数据库。备份时同样需要明确哪些目录可再生、哪些是用户唯一副本。控制磁盘占用应依据数据类别和保留规则，而不是对整个 userData 做不加区分的递归清理。

若将配置能力接入窗口，main 应先校验发送者，再校验 expectedRevision 与 theme，然后调用 store.update。来源校验和字段校验不是仓库模块的职责替代品；仓库保证自身数据合同，IPC 层保证谁能够发起操作。保持分层后，Node 实验可以验证文件与队列，桌面实验专门验证窗口和权限。

## 实验二：升级配置格式，不把旧文件直接改坏

假设第一版用 darkMode 布尔值，第二版改成 theme 字符串。迁移不是 JSON 字段随手替换：旧版本号是否可信，缺失值怎么办，将来版本能否读取，以及失败时是否保留原文件，都要定义。本实验只输出迁移计划，不写入用户目录。保存以下两个文件，执行 node migration-check.cjs。

```javascript config-migration.cjs
function migrateConfig(value) {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error("INVALID_CONFIG");
  }
  if (value.schemaVersion === 1) {
    if (typeof value.darkMode !== "boolean") throw new Error("INVALID_V1");
    return { schemaVersion: 2, revision: 0,
      theme: value.darkMode ? "dark" : "light" };
  }
  if (value.schemaVersion === 2) {
    if (!Number.isSafeInteger(value.revision) || value.revision < 0 ||
        !["light", "dark"].includes(value.theme)) throw new Error("INVALID_V2");
    return { schemaVersion: 2, revision: value.revision, theme: value.theme };
  }
  throw new Error("UNSUPPORTED_SCHEMA");
}
module.exports = { migrateConfig };
```

```javascript migration-check.cjs
const assert = require("node:assert/strict");
const { migrateConfig } = require("./config-migration.cjs");
const old = { schemaVersion: 1, darkMode: true };
const snapshot = JSON.stringify(old);
const current = migrateConfig(old);
assert.deepEqual(current, { schemaVersion: 2, revision: 0, theme: "dark" });
assert.equal(JSON.stringify(old), snapshot);
assert.deepEqual(migrateConfig(current), current);
assert.throws(() => migrateConfig({ schemaVersion: 1, darkMode: "true" }), /INVALID_V1/);
assert.throws(() => migrateConfig({ schemaVersion: 99 }), /UNSUPPORTED_SCHEMA/);
console.log("migration checks passed");
```

预期输出 migration checks passed。迁移函数不修改传入对象，因此失败不会留下半个新结构；已是第二版时返回等价结果，重复执行不会不断增加 revision。未知的更高版本明确拒绝，防止旧应用把新文件按旧合同重写。schemaVersion 不是应用版本号，应用修复样式时不需要无意义地迁移数据。

真正提交迁移时，先保存受保护的旧版本副本或可恢复快照，在独立目标验证新结构，再按清晰提交点切换。不要同时用新旧应用长期写同一个 JSON 文件。用户选择降级应用时，新文件不一定向后兼容，应该提示需要恢复副本或使用兼容版本，不能让旧代码凭字段缺失推断“全新安装”。

## SQLite 何时更合适，原生依赖为何特殊

配置只有几个字段时，整个文件重写容易理解；记录增多、需要索引、跨表约束、事务和并发查询时，SQLite 更合适。把几万条笔记塞进一个 JSON 数组，每次编辑都序列化并替换整文件，会增加延迟与故障影响面。数据库提供更清楚的查询和事务边界，但仍需要 schema 迁移、备份、完整性检查与应用级冲突处理。

不少 SQLite 驱动包含原生二进制模块。系统 Node 能加载，不代表 Electron 内置 Node 能加载；运行时 ABI、平台和 CPU 架构都必须匹配。使用 Electron 目标重新构建或安装对应预构建产物，是解决兼容问题的方向，不是随机删除锁文件重装。打包后原生文件还可能需要从 asar 解包，具体由驱动与打包工具决定。[原生模块](https://www.electronjs.org/docs/latest/tutorial/using-native-node-modules)

同步数据库调用可能阻塞主进程，异步封装也不保证内部没有耗时 CPU 工作。先测量查询规模与执行时长，再决定是否放到专门工作线程或 utility process。把一个连接随意跨线程共享通常不成立；连接由对应执行环境拥有，其他进程通过受限消息提交业务操作。下一章会详细讨论任务隔离。

## 本地、私有与加密是不同概念

文件在 userData 只表示存放位置，不表示密文。系统用户、备份工具、恶意软件或有足够权限的其他程序可能读取它。零六〇〇权限也不等于加密，Windows 上还有不同的访问控制机制。应先区分普通偏好、业务内容和长期凭据，不要因为“没有上传服务器”就承诺数据对本机其他主体不可见。

Electron safeStorage 可以利用平台提供的机制加密小型字符串，但平台保障并不完全一致。使用前检查加密是否可用，并理解 Linux 上可用后端与 basic_text 等退化情况；不能把有返回值当作所有机器上都有同等保护。加密还需要考虑备份恢复、用户切换和密钥不可用时如何处理，不应失败后悄悄把令牌写成明文。[safeStorage](https://www.electronjs.org/docs/latest/api/safe-storage)

日志和错误消息也是持久化通道。即使凭据文件加密，调试时打印整个请求对象仍可能把令牌写进日志。建议保存操作编号、字节数、schema 版本和错误码，不保存正文与密钥；用户主动导出诊断时先明确范围。数据删除也包括备份、临时文件和历史日志，不只删除当前 settings.json。

## 保存按钮与磁盘提交之间，还隔着产品语义

用户点击保存后，界面可以立即显示正在保存，但应等待仓库 Promise 成功才显示已保存。若你为了流畅先更新界面，再发生磁盘错误，就要明确保留未保存草稿并提供重试，而不是把旧数据重新覆盖到输入框。乐观界面状态和持久化提交是两个状态，桌面应用没有远程服务器也同样需要区分。

自动保存还要解决频率问题。每次键入都提交完整配置，会制造大量写入与版本变化；可以在界面层合并短时间连续编辑，但退出前必须等待最后一次已接受保存完成。防抖不是可靠队列，它只减少发起次数。窗口关闭时若还有未提交编辑，应依业务规则保存、提示或放弃，并让用户知道结果。

这里的 expectedRevision 使冲突可见，但它不会自动合并。两个窗口分别修改不同字段时，可以重新读取当前版本后只重放自己的字段补丁；两边修改同一字段时，可能需要用户选择。若直接将旧窗口完整对象重新提交，只会把冲突检查变成无限重试，最终仍可能覆盖别人修改。版本错误应触发重新理解当前状态，而不是机械重试。

## 重命名前后的故障，需要不同恢复动作

临时文件还未完成写入时退出，旧目标应继续可用，残留临时文件可在下次启动时按明确规则识别。rename 已完成但尚未向 UI 返回时退出，用户可能不确定保存是否成功；重新启动后读取 revision 与内容，才能确认提交结果。不能因为调用者没收到成功响应，就认定磁盘上一定没有写入。

临时文件名包含随机标识有助于避免碰撞，但自动清理不能只凭扩展名就删除整个目录里所有 .tmp。它们可能属于正在运行的任务或其他组件。真实应用可使用独立暂存目录、任务记录和过期策略，先验证文件归属与任务状态，再回收。清理应是可重试维护动作，不要与读取正常配置混成一段破坏性启动脚本。

如果需要保留上一份有效配置，备份提交顺序也要设计。先移动旧文件再写新文件，可能造成目标暂时不存在；先覆盖备份则可能失去最后已知良好版本。更严格的方案可以使用不可变版本文件与一个当前版本指针，但会增加回收、提交与恢复复杂度。小型配置选择足够清楚的保证，比给复杂方案贴上原子标签更有帮助。

## 数据规模改变后，重新审视这份实现

实验读取与写入整个 JSON 文件，只适合有明确上限的小配置。增加大量正文、附件或索引后，JSON.parse 与 stringify 本身会消耗 CPU，异步 readFile 不会把解析过程移出主线程。应给文件大小设上限，并在数据规模发生变化时重新选择存储与执行位置，不能因为扩展名仍叫配置就继续使用同一种实现。

同样，备份策略应该依据数据能否重建。窗口大小可以重置，用户唯一一份正文不能轻易丢失；缓存索引可以从原文再生，但权限关系和编辑历史可能没有替代来源。将这些数据混在同一个文件中，会让恢复和清理的风险捆在一起。目录布局应反映真实生命周期，而不只是为了文件列表看起来整齐。

## 练习与验收：证明保存失败没有伪装成功

练习要求在第一次提交前模拟一次错误，随后恢复正常提交，确认队列没有永久失败；同时把损坏配置文件加载错误传给调用者。提示是故障注入只执行一次，并用状态读取验证之前成功值。下面是完整独立检查文件，与 settings-store.cjs 同目录运行。

<details><summary>完整参考实现：recovery-check.cjs</summary>

```javascript recovery-check.cjs
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { createStore } = require("./settings-store.cjs");
(async () => {
  const tempBase = await fs.realpath(os.tmpdir());
  const tempPrefix = "recovery-lab-";
  const root = await fs.mkdtemp(path.join(tempBase, tempPrefix));
  try {
    let failOnce = true;
    const store = createStore(path.join(root, "config.json"), {
      beforeCommit: async () => {
        if (failOnce) { failOnce = false; throw new Error("DISK_SIMULATION"); }
      }
    });
    await assert.rejects(() => store.update(0, "dark"), /DISK_SIMULATION/);
    assert.equal((await store.read()).revision, 0);
    assert.equal((await store.update(0, "dark")).revision, 1);
    const corrupt = path.join(root, "corrupt.json");
    await fs.writeFile(corrupt, "{broken", "utf8");
    const damaged = createStore(corrupt);
    await assert.rejects(() => damaged.read(), SyntaxError);
    await assert.rejects(() => damaged.read(), /STORE_NOT_READY/);
    await assert.rejects(() => damaged.read(), /STORE_NOT_READY/);
    await assert.rejects(() => damaged.update(0, "dark"), /STORE_NOT_READY/);
    assert.equal(await fs.readFile(corrupt, "utf8"), "{broken");
    console.log("recovery checks passed");
  } finally { const resolved = await fs.realpath(root);
    if (path.dirname(resolved) !== tempBase || !path.basename(resolved).startsWith(tempPrefix)) {
      throw new Error("UNSAFE_TEMP_CLEANUP");
    }
    await fs.rm(resolved, { recursive: true, force: true }); }
})().catch(error => { console.error(error); process.exitCode = 1; });
```

</details>

验收不只看成功提示：旧 revision 冲突必须被拒绝，rename 前失败不得改变目标，临时文件应清理，进程重新读取应得到最后成功状态，损坏配置应保留。以上 Node 实验已经实际通过；没有执行真实掉电、网络文件系统、Electron GUI、SQLite 原生驱动安装或生产数据迁移。

自测一：排队能避免所有覆盖吗？不能，旧窗口仍需版本比较，多进程还需额外协调。自测二：rename 成功能证明断电安全吗？不能，它与介质和目录持久性有关。自测三：userData 中保存令牌就是加密吗？不是，目录位置、访问控制与加密必须分别讨论。

## 本章验证记录

Node 22.22.0 已运行 settings-check.cjs、migration-check.cjs 与 recovery-check.cjs，覆盖写入冲突、提交前失败、旧文件保留、重新读取、迁移、初始化错误后的重复读取拒绝与后续写入拒绝。清理前核对真实绝对目录和前缀。Electron 目录入口仅语法检查；未执行 GUI、原生 SQLite 驱动、真实断电或用户目录写入。
