跳转到内容

DB

DB 提供一层轻量的 IndexedDB Promise API,用来封装 browser / WebView 应用里的结构化本地数据库。

它解决的是 IndexedDB 基础问题:open/create、version upgrade、object store、index、基础 CRUD、Promise 化和类型约束。它不负责业务 repository、自动迁移、自动 key 前缀、schema diff、加密、跨 tab 同步、网络同步队列或复杂 query builder。

什么时候使用

场景推荐 API说明
大体积结构化数据databaseManager.open例如离线题目、词典数据、答题记录。
需要按字段查询的数据store.index(...)例如按 courseIdupdatedAt 查询。
需要 version migrationupgradeIndexedDB 升级必须由业务显式控制。
小体积状态createStorage()主题、开关、短期 session 状态不需要 DB。

能力边界

DB 是独立的 IndexedDB basic facade,不是通用数据库框架,也不是 Dexie、RxDB 或 idb 的完整替代品。

它当前只承诺封装:

  • IndexedDB open/create 和 version upgrade。
  • object store / index 的声明、初始创建和打开后校验。
  • 基础 CRUD、index 查询、Promise 化和 DbError 失败语义。

它不承诺提供:

  • cursor、复杂 query builder、schema diff、自动 migration。
  • 多 store transaction 编排、批量写入优化、live query。
  • 加密、跨 tab 同步、网络同步队列、冲突解决。

如果业务需要复杂本地数据库能力,优先在业务 repository 层评估 Dexie、RxDB、SQLite 或其它成熟数据库方案。databaseManager 的职责是提供统一 runtime 入口和基础 IndexedDB 约束,不接管业务 repository。

快速示例

ts
import { createDb } from '@freenotes/web-runtime';

const databaseManager = createDb();

interface AppDbSchema {
  books: {
    key: string;
    value: {
      courseId: string;
      id: string;
      title: string;
      updatedAt: number;
    };
    indexes: {
      byCourseId: string;
      byUpdatedAt: number;
    };
  };
}

const db = await databaseManager.open<AppDbSchema>({
  name: 'question-bank',
  stores: {
    books: {
      keyPath: 'id',
      indexes: {
        byCourseId: 'courseId',
        byUpdatedAt: 'updatedAt'
      }
    }
  }
});

await db.store('books').add({
  courseId: 'cet4',
  id: 'book-1',
  title: 'English',
  updatedAt: Date.now()
});

const book = await db.store('books').get('book-1');

databaseManager.open() 会立即访问 IndexedDB。如果 database 不存在,会创建;如果版本需要升级,会执行 upgrade 流程;如果打开失败,Promise 会 reject。

version 不是必填字段。省略时,IndexedDB 会打开当前已有版本;如果 database 不存在,会创建 version 1。只有需要明确触发 migration 时,才需要传入更高的 version 并配合 upgrade

这段声明可以读作:

  • books 是一个 object store,可以粗略理解为一张表。
  • keyPath: 'id' 表示每条 book 用自己的 id 字段作为主键。
  • byCourseId: 'courseId' 表示创建一个名为 byCourseId 的索引,索引值来自 book 的 courseId 字段。
  • byUpdatedAt: 'updatedAt' 表示创建一个名为 byUpdatedAt 的索引,索引值来自 book 的 updatedAt 字段。

类型推导

不传 schema 泛型时,open() 会从 definition 字面量推导 store 名和 index 名:

ts
const db = await databaseManager.open({
  name: 'question-bank',
  stores: {
    books: {
      keyPath: 'id',
      indexes: {
        byTitle: 'title'
      }
    }
  }
});

db.store('books').index('byTitle'); // store / index 名有类型检查

这种简写无法从字符串 keyPath 安全推导 value 和 key 的具体结构,所以 value 保持 unknown,key 使用 IDBValidKey。需要约束读写值、主键和 index query 类型时,应像快速示例一样显式传入 AppDbSchema。显式 schema 也会限制 definition 中允许声明的 index 名,拼错或声明 schema 之外的 index 会在 TypeScript 阶段报错。

为什么不是 databaseManager.books

DB 不提供:

ts
databaseManager.books.add(book);

原因是 IndexedDB 的第一层是 database,不是 store。一个应用里也可能有多个 database。把 store 平铺到 manager 会绑定业务 schema,也会隐藏 database name、version 和 migration 这些 IndexedDB 核心语义。

推荐把 database handle 留在业务集成层:

ts
export const dbPromise = databaseManager.open<AppDbSchema>({
  name: 'question-bank',
  stores
});

再由业务 repository 收敛具体操作:

ts
export const bookRepository = {
  async add(book: Book) {
    const db = await dbPromise;
    return db.store('books').add(book);
  },

  async get(id: string) {
    const db = await dbPromise;
    return db.store('books').get(id);
  }
};

Store 操作

ts
await db.store('books').add(book);
await db.store('books').put(book);
await db.store('books').get('book-1');
await db.store('books').getAll();
await db.store('books').count();
await db.store('books').delete('book-1');
await db.store('books').clear();

如果 store 使用 keyPathadd / put 不需要额外传 key。没有 keyPath 时,可以传第二个参数:

ts
await db.store('books').add(book, 'book-1');

主键来源遵循 IndexedDB 原生规则:

Store 定义主键来源写入方式
{ keyPath: 'id' }从 value 的 id 字段读取add({ id: 'book-1', ...book })
{ autoIncrement: true }IndexedDB 自动生成数字 keyconst key = await add(book)
{}调用方手动传入add(book, 'book-1')

当前 TypeScript 类型只根据 AppDbSchema 约束 value 类型、key 类型、store 名和 index 名,不会根据 stores.books.keyPath / autoIncrement 自动推导第二个 key 参数是否必填或禁用。因此:

ts
// keyPath store:不要传第二个 key。
await db.store('books').add({
  id: 'book-1',
  title: 'English'
});

// out-of-line key store:没有 keyPath,也没有 autoIncrement 时,必须手动传 key。
await db.store('books').add(
  {
    title: 'English'
  },
  'book-1'
);

// autoIncrement store:通常不传 key,使用 add() 返回的 generated key。
const generatedKey = await db.store('books').add({
  title: 'English'
});

database definition 会在打开前校验 IndexedDB keyPath:字符串必须是空字符串或由点分隔的合法属性名,compound keyPath 必须是非空字符串数组。autoIncrement 不能与空字符串或 compound keyPath 同时使用,multiEntry index 也不能使用 compound keyPath。非法组合会直接 reject invalid-definition,不会等到 upgrade 阶段才失败。

如果违反这些规则,IndexedDB 会让对应操作失败,runtime 会把失败包装成 DbError reject。DB 模块暂时不把这套主键来源规则做进类型系统,避免为了少数场景引入过重的泛型和 builder API。

Index 查询

ts
const books = await db.store('books').index('byCourseId').getAll('cet4');

const latest = await db.store('books').index('byUpdatedAt').get(Date.now());

getAll 可以限制数量:

ts
const firstTen = await db.store('books').index('byCourseId').getAll('cet4', {
  count: 10
});

普通索引可以直接写成 indexName: keyPath。需要 uniquemultiEntry 或联合字段索引时,可以使用完整对象:

ts
const db = await databaseManager.open({
  name: 'question-bank',
  stores: {
    books: {
      keyPath: 'id',
      indexes: {
        byCourseId: 'courseId',
        byTags: {
          keyPath: 'tagIds',
          multiEntry: true
        },
        byBookAndStatus: {
          keyPath: ['bookId', 'status']
        }
      }
    }
  }
});

Migration

初次创建数据库时,runtime 会根据 stores 自动创建 object stores 和 indexes。

已有数据库升级时,runtime 不自动 diff,不自动删除或重建 store/index。业务必须通过 upgrade 显式处理历史迁移:

ts
const db = await databaseManager.open<AppDbSchema>({
  name: 'question-bank',
  version: 2,
  stores,
  upgrade({ database, oldVersion, transaction }) {
    if (oldVersion < 2) {
      const books = transaction.objectStore('books');

      if (!books.indexNames.contains('byUpdatedAt')) {
        books.createIndex('byUpdatedAt', 'updatedAt');
      }

      if (!database.objectStoreNames.contains('tickets')) {
        database.createObjectStore('tickets', { keyPath: 'id' });
      }
    }
  }
});

upgrade 必须是同步回调,不能声明为 async,也不能返回 Promise。跨 await 无法可靠保持 IndexedDB 的 versionchange transaction 活跃;runtime 检测到 Promise-like 返回值时会 abort transaction,并 reject upgrade-failed。所有 store / index 结构变更必须在 upgrade 回调返回前完成。数据迁移可以在回调内发起原生 IDB request,并在这些 request 的事件回调中继续安排同一事务内的数据读写。

创建或升级数据库时,runtime 在 upgrade 回调返回后、升级事务提交前,校验声明的 store 和 index 是否存在,以及 keyPathautoIncrement、index keyPathuniquemultiEntry 是否一致。校验失败会 abort 整个升级事务,回滚版本、结构和该事务中的数据修改,再以对应的 DbError reject。修正 migration 后可以使用同一目标版本重试。

打开已有数据库而未触发升级时,仍会校验声明与实际结构是否一致;不匹配会 reject,不修改数据库。

失败语义

open()deleteDatabase() 和 CRUD / index 查询失败会 reject DbError。获取句柄的 db.store(name)store.index(name) 是同步方法:store 或 index 不存在时同步抛 DbError;在已关闭的连接上获取 index 时也可能同步抛 transaction-failed

把句柄获取和 await 数据操作一起放进 try/catch;仅在链末尾添加 .catch() 接不住同步异常。DbError 可以从根入口导入,并通过 error instanceof DbError 识别:

ts
try {
  const db = await databaseManager.open<AppDbSchema>(definition);
  const book = await db.store('books').get('book-1');
} catch (error) {
  // 可以按 DbError 处理。
}

常见错误类型:

错误类型含义
database-unavailable当前环境没有 IndexedDB。
invalid-definitiondatabase definition 不合法。
open-failed打开 database 失败。
upgrade-failedversion upgrade 失败。
store-not-found声明或访问的 object store 不存在。
index-not-found声明或访问的 index 不存在。
request-failedIndexedDB request 失败。
transaction-failedIndexedDB transaction abort 或 error。

get() 找不到值时不会 reject,而是 resolve undefined。这和真实失败不同。

如果 open / delete 被其它连接阻塞,对应 Promise 会保持 pending,直到阻塞连接关闭后请求真正成功或失败。runtime 创建的 database handle 会在 versionchange 时自动关闭;如果阻塞来自 runtime 外部直接创建的 IndexedDB 连接,需要由连接持有方主动关闭。这样可以避免 Promise 已 reject、但无法取消的底层请求随后仍完成升级或删除。

API 速查

方法说明
databaseManager.open(definition)打开或创建 IndexedDB database。
databaseManager.deleteDatabase(name)删除 database。
databaseManager.isAvailable()判断当前环境是否存在 IndexedDB。
db.store(name)获取 typed object store handle。
store.add(value, key?)新增数据,返回 IndexedDB key。
store.put(value, key?)新增或覆盖数据,返回 IndexedDB key。
store.get(query)读取单条数据。
store.getAll(query?, options?)读取多条数据。
store.delete(query)删除单条或范围数据。
store.clear()清空当前 object store。
store.count(query?)统计数量。
store.index(name)获取 typed index handle。
index.get(query)通过 index 读取单条数据。
index.getAll(query?, options?)通过 index 读取多条数据。
index.count(query?)通过 index 统计数量。

完整类型定义见 API 参考

让基础能力保持简单,让业务开发更加专注。