外观
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(...) | 例如按 courseId、updatedAt 查询。 |
| 需要 version migration | upgrade | IndexedDB 升级必须由业务显式控制。 |
| 小体积状态 | 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 使用 keyPath,add / 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 自动生成数字 key | const 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。需要 unique、multiEntry 或联合字段索引时,可以使用完整对象:
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 是否存在,以及 keyPath、autoIncrement、index keyPath、unique、multiEntry 是否一致。校验失败会 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-definition | database definition 不合法。 |
open-failed | 打开 database 失败。 |
upgrade-failed | version upgrade 失败。 |
store-not-found | 声明或访问的 object store 不存在。 |
index-not-found | 声明或访问的 index 不存在。 |
request-failed | IndexedDB request 失败。 |
transaction-failed | IndexedDB 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 参考。