Webiny CMS SQL 存储简化方案:用 DDB 式"全量加载 + 内存过滤"重构 `api-headless-cms-sql`
Webiny CMS SQL 存储简化方案:用 DDB 式"全量加载 + 内存过滤"重构 api-headless-cms-sql
本文基于仓库设计文档 docs/.bruno/specs/2026-06-03-cms-sql-storage-simplification-design.md,系统讲解 Webiny Headless CMS 如何将 SQL 存储后端从"表级建模 + 动态列 + SQL 查询构造器"的复杂体系,重构为与 DynamoDB 后端同构的"固定 schema 表 + JSON values blob + 应用层内存过滤/排序/分页"方案。读者读完将掌握:新表结构与 Mapper 设计、共享过滤工具包 @webiny/db-utils 的抽取方式与 DI 接线、list/get 操作的内存过滤流水线、各写操作的标志位翻转规则、不变量约束,以及测试验证策略。
一、为什么简化:背景、动机与边界
背景与动机
当前 api-headless-cms-sql 包实现了一套"完整的关系型存储层":表级建模(table-per-model)、动态列管理、SQL WHERE 构造器、操作符注册表(operator registry)、过滤注册表(filter registry)、keyset 分页、以及基于 ALTER TABLE 的 schema diff 机制。这套体系面向通用关系型数据库场景,但对本文的目标场景——小型项目的 SQLite——属于过度设计,并且正是 42 个剩余测试失败的根源。
设计文档给出的判断非常直接:
The DDB-only system already solves filtering by loading all entries for a model and filtering in application code. The same approach works for SQLite at small scale, and eliminates the entire SQL query-building layer.
即:DDB 后端早已通过"加载某个模型的全部条目、在应用代码中过滤"解决了查询问题;同样的思路在 SQLite 的小规模场景下完全成立,并且可以直接消灭整层 SQL 查询构造代码。
目标(Goals)
- 条目数据的存储方式与 DDB 系统保持一致:扁平系统列 + values blob。
- 加载所有匹配条目,在应用代码中完成过滤、排序、分页。
- 把 DDB 的过滤工具抽取为共享包,两个存储后端复用同一套代码。
- 快速交付——尽量少的活动部件(minimize moving parts)。
非目标(Non-Goals)
- SQL 层面的查询优化(SQLite 规模下不需要)。
- 支持百万级条目(那是 DDB + OpenSearch 的职责)。
- 与当前 SQL 表 schema 的向后兼容。
二、包级变更总览
本次重构涉及四个包,方向清晰:
| 包 | 动作 |
|---|---|
@webiny/db-utils |
新建,承载从 api-headless-cms-ddb 抽取的共享过滤/排序/值比较工具,零 DynamoDB 依赖 |
@webiny/api-headless-cms-sql |
重写:固定 schema 三表 + 内存过滤流水线,删除整层 SQL 查询构造代码 |
@webiny/api-headless-cms-ddb |
纯机械的 import 改指向,无逻辑变更 |
@webiny/db-dynamodb |
迁出 ValueFilter/ValueFilterRegistry 抽象,内部 re-export 兼容 |
三、新共享包 @webiny/db-utils
3.1 目录结构与文件职责
设计文档给出了完整的目录规划,每个文件/目录承担单一职责:
packages/db-utils/src/
├── valueFilter/
│ ├── abstractions.ts -- ValueFilter + ValueFilterRegistry 抽象
│ └── feature.ts -- DI 注册
├── filtering/
│ ├── filter.ts -- 对记录执行表达式树(expression tree)
│ ├── sort.ts -- 内存排序(lodash sortBy,仅支持单字段)
│ ├── fullTextSearch.ts -- 跨字段词条匹配
│ ├── getValue.ts -- 递归点路径(dot-path)取值
│ ├── transform.ts -- 值变换包装
│ ├── mapPlugins.ts -- 通用插件容器工具(按 key 映射插件)
│ ├── expressions/
│ │ ├── createExpressions.ts -- 把 where 输入编译为表达式树
│ │ ├── where.ts -- 解析 "field_not_in" → { fieldId, op, negate }
│ │ └── values.ts -- AND/OR 数组校验
│ ├── fields/
│ │ ├── createFields.ts -- 从模型字段构建 Field 查找表
│ │ ├── systemFields.ts -- 系统/元数据字段纳入可过滤集合
│ │ ├── extractSort.ts -- 解析排序字符串为字段 + 方向
│ │ └── types.ts -- Field、FieldParent、FilterItemFromStorage
│ └── plugins/
│ ├── defaultFilterCreate.ts
│ ├── refFilterCreate.ts
│ ├── objectFilterCreate.ts
│ ├── searchableJsonFilterCreate.ts
│ └── index.ts
├── plugins/
│ ├── FieldFilterPlugin.ts
│ ├── FieldSortingPlugin.ts
│ ├── FieldFilterPathPlugin.ts
│ └── FieldFilterValueTransformPlugin.ts
├── path/
│ ├── plainObject.ts
│ └── locationFolderId.ts
└── transforms/
└── datetime.ts -- 自包含的日期/时间解析(datetime + time 两个分支)
3.2 每个文件组的来源
设计文档以表格明确了"目的地 ← 来源",保证抽取过程可追溯、可审查:
| 目的地 | 来源 |
|---|---|
valueFilter/ |
@webiny/db-dynamodb — ValueFilter + ValueFilterRegistry 抽象 |
filtering/ |
api-headless-cms-ddb/src/operations/entry/filtering/ |
plugins/ |
api-headless-cms-ddb/src/plugins/(基类) |
path/ |
api-headless-cms-ddb/src/dynamoDb/path/ |
transforms/datetime.ts |
api-headless-cms-ddb/src/dynamoDb/transformValue/datetime.ts(重写以移除 @webiny/db-dynamodb 依赖——其逻辑本质只是 date-fns 解析加整数运算) |
3.3 DDB 耦合的消除
ValueFilter与ValueFilterRegistry抽象从@webiny/db-dynamodb迁入@webiny/db-utils。filter.ts、createExpressions.ts、fullTextSearch.ts、FieldFilterPlugin.ts改为从@webiny/db-utils导入这些抽象,不再依赖@webiny/db-dynamodb。datetime.ts变换被重写为内联日期/时间解析:包含datetime(经date-fns/parseISO)与time(整数运算)两个分支,不再导入TimeTransformPlugin/DateTimeTransformPlugin。
3.4 抽取导致的签名变化
这是共享化最关键的 API 契约调整:
filter()原接收container: CmsContext["container"]并通过 DI 解析ValueFilterRegistry;抽取后直接接收valueFilterRegistry: ValueFilterRegistry.Interface作为参数,由调用方在调用前从自己的容器中解析。createExpressions()同样改为以参数接收valueFilterRegistry,不再从容器解析。sort()强制单字段排序(与 DDB 一致),不支持多字段排序。
3.5 DI 接线要求
api-headless-cms-ddb 与 api-headless-cms-sql 都必须在 DI 容器中注册来自 @webiny/db-utils 的 ValueFilterFeature,使 ValueFilterRegistry 可被解析;SQL 包的 registerSqlStorageOperations() 必须包含该 feature 注册。
3.6 源码级印证:抽象与内置操作符
从当前仓库源码可以看到 @webiny/db-utils 的实际落地形态。ValueFilter 抽象的核心契约非常精简(packages/db-utils/src/valueFilter/abstractions/ValueFilter.ts):
export interface IValueFilter<TValue = any, TCompareValue = any> {
readonly operation: string;
is(operation: string): boolean;
canUse(params: IValueFilterCanUseParams): boolean;
matches(params: IValueFilterMatchesParams<TValue, TCompareValue>): IValueFilterMatchesResult;
}
ValueFilterRegistry 提供 get(operation) 与 getAll() 两个查询入口(ValueFilterRegistry.ts)。ValueFilterFeature 一次性向容器注册 11 个内置操作符(feature.ts):
EqFilter、GtFilter、GteFilter、LtFilter、LteFilter、BetweenFilter、
InFilter、AndInFilter、ContainsFilter、FuzzyFilter、StartsWithFilter
fuse.js(模糊匹配)与 @webiny/error 是 db-utils 仅有的外部运行时依赖(见 packages/db-utils/package.json),保持了"零 DynamoDB 依赖"的目标。
需要说明的落地差异:设计文档把
filtering/、plugins/、path/、transforms/全部规划进@webiny/db-utils;而仓库实际实现中,过滤核心工具(filter.ts、sort.ts、fullTextSearch.ts、getValue.ts、createExpressions.ts、fields/、plugins/等)最终落在@webiny/api-headless-cms-storage包的 packages/api-headless-cms-storage/src/filtering 目录,db-utils则承载 valueFilter 抽象与注册表。共享的思想与"两后端同一套过滤代码"的目标完整保留,只是物理位置有所调整。
SQL 包在 feature 注册时通过 FilterRegistriesFeature(来自 @webiny/api-headless-cms-storage)接入这些过滤注册表,见 packages/api-headless-cms-sql/src/index.ts。
四、重写 @webiny/api-headless-cms-sql
4.1 表结构:三张固定 schema 表
命名规则:{prefix}webiny_cms_entries{suffix}、{prefix}webiny_cms_groups{suffix}、{prefix}webiny_cms_models{suffix},前缀和后缀均可选。
webiny_cms_entries——每个修订一行:
| 列 | 类型 | 说明 |
|---|---|---|
id |
TEXT, PK | "entryId#version" |
entryId |
TEXT, indexed | 裸条目 ID |
modelId |
TEXT, indexed | 模型标识 |
tenant |
TEXT, indexed | 租户标识 |
version |
INTEGER | 修订号 |
status |
TEXT | "draft" / "published" / "unpublished" |
locked |
BOOLEAN | 发布时置位 |
isLatest |
BOOLEAN, indexed | 每个 entryId 至多一个 |
isPublished |
BOOLEAN, indexed | 每个 entryId 零或一个 |
wbyDeleted |
BOOLEAN | 回收站标志 |
binOriginalFolderId |
TEXT | 进回收站前的原始文件夹 |
location |
TEXT | JSON { folderId } |
location_folderId |
TEXT | 冗余列,便于人工检视 |
revisionCreatedOn … revisionLastPublishedOn |
TEXT | 7 个修订级日期 |
revisionCreatedBy … revisionLastPublishedBy |
TEXT | 7 个修订级身份 JSON blob |
createdOn … lastPublishedOn |
TEXT | 7 个条目级日期 |
createdBy … lastPublishedBy |
TEXT | 7 个条目级身份 JSON blob |
meta |
TEXT | JSON |
system |
TEXT | JSON |
live |
TEXT | JSON { version } |
revisionDescription |
TEXT | |
expiresAt |
BIGINT | TTL |
values |
TEXT | 全部字段值的 JSON blob |
设计文档特别强调:不再保留扁平身份列(_id、_displayName、_type)——它们此前仅服务于 SQL WHERE 子句,而 WHERE 子句已被删除;内存过滤直接从解析后的条目对象读取属性。
复合索引(三条):
(tenant, modelId, isLatest)(tenant, modelId, isPublished)(tenant, modelId, entryId)— 用于修订列表
Schema 创建: 首次访问时执行一次 CREATE TABLE IF NOT EXISTS。没有动态 ALTER TABLE、没有 schema 注册表、没有 cms_table_schemas 元数据表。
webiny_cms_groups 与 webiny_cms_models 保持现有固定 schema,仅简单 CRUD。
4.2 条目 Mapper
entryToRow(entry, model, options: { isLatest, isPublished })
- 直接铺开(spread)所有系统/元数据字段;
modelId取自model.modelId(而非 entry——entry 可能不携带它);tenant取自model.tenant;JSON.stringify(values)写入values列;- 对身份对象、location、meta、system、live 分别
JSON.stringify; isLatest、isPublished由 options 决定。
rowToEntry(row)
JSON.parse(values)还原字段值对象;JSON.parse还原身份 blob、location、meta、system、live;- 返回带类型的
CmsStorageEntry。
不再有字段列迭代、哈希列命名、null-object 折叠。
4.3 get 操作
get 等价于 limit=1 的 list:基于 where.isLatest / where.isPublished 加载匹配行,对 where 子句做内存过滤,可选排序,返回第一条匹配或 null。
4.4 list 操作:三种查询模式 + 内存过滤流水线
三种查询模式(均以固定列缩小候选集,随后统一进入内存过滤):
-- 1. 最新条目
SELECT * FROM entries WHERE tenant=? AND modelId=? AND isLatest=true
-- 2. 已发布条目
SELECT * FROM entries WHERE tenant=? AND modelId=? AND isPublished=true
-- 3. 条目修订
SELECT * FROM entries WHERE tenant=? AND modelId=? AND entryId=?
加载行之后的标准流水线:
- 逐行
rowToEntry(); - 按字段应用 CMS
fromStorage变换; - 调用
@webiny/db-utils的filter()——内存表达式树求值; totalCount = filteredItems.length;- 调用
@webiny/db-utils的sort()——内存排序; - 基于偏移量分页:
slice(start, start + limit);cursor 为 base64 编码的整数偏移量(与 DDB 相同); - 返回
{ items, totalCount, hasMoreItems, cursor }。
4.5 写操作
写操作沿用当前 SQL 实现的标志位翻转逻辑;Mapper 简化后,条目级元数据传播只使用 JSON blob 列(如 modifiedBy、savedBy),不再有被移除的 _id/_displayName/_type 变体,ENTRY_LEVEL_META_FIELDS 需相应更新。
| 操作 | SQL 行为 |
|---|---|
create |
INSERT,isLatest=true,isPublished 依 status |
createRevisionFrom |
旧最新行 UPDATE isLatest=false,新行 INSERT isLatest=true |
update |
UPDATE 当前行,并将条目级元数据传播到所有修订(WHERE entryId=?) |
publish |
旧已发布行 UPDATE isPublished=false, status="unpublished";新行 UPDATE isPublished=true;条目级元数据与 live 传播到所有修订 |
unpublish |
UPDATE isPublished=false,向所有修订传播 live=null |
move |
UPDATE 所有修订:location 字段 |
moveToBin |
UPDATE 所有修订:wbyDeleted=true, isPublished=false, live=null,条目级元数据 |
restoreFromBin |
UPDATE 所有修订:wbyDeleted=false;找到最高版本,置 isLatest=true |
deleteRevision |
DELETE 该行,处理标志级联以产生新的最新行 |
delete |
DELETE WHERE entryId |
deleteMultipleEntries |
DELETE WHERE entryId IN (...) |
4.6 getUniqueFieldValues
加载所有匹配条目(最新/已发布),解析 values JSON,抽取目标字段,内存聚合——与 DDB 完全一致的做法。
4.7 保留的特性
以下现有特性保留(用法简化):
features/knexInstance/— Knex 实例抽象与工厂注册features/tableNameResolver/— TableNameResolver(更新为新表命名:{prefix}webiny_cms_entries{suffix}等)features/groupSchemaManager/— groups 表固定CREATE TABLE IF NOT EXISTSfeatures/modelSchemaManager/— models 表固定CREATE TABLE IF NOT EXISTSoperations/group/— 分组 CRUD(简单 SQL,基本不变)operations/model/— 模型 CRUD(简单 SQL,基本不变)
新增的 EntryTableManager(或类似物)取代 EntrySchemaManager——仅固定列集的 CREATE TABLE IF NOT EXISTS,无 ALTER TABLE、无 schema diff。
4.8 删除的代码
以下内容从 SQL 包中整体移除:
whereBuilder.ts—applyWhere、applySearch、applyKeysetConditionfeatures/sqlOperator/— 全部 14 个操作符 + 注册表features/sqlEntryFilter/— DefaultFilter、ObjectFilter、RefFilter + 注册表features/entrySchemaManager/— 动态 schema、ALTER TABLE、columnBuilderfeatures/schemaRegistry/— 已验证表缓存features/fieldTypeMapper/— CMS 字段到 SQL 列映射utils/columnName.ts— 哈希列命名utils/parseWhereKey.ts、utils/parseSortField.tsutils/cursor.ts— keyset cursor(被 offset cursor 取代)
五、源码级对照:当前实现的关键细节
设计文档交付后,仓库已落地为可直接读到的实现。以下几点与文档互相印证,并补充了文档未展开的工程细节。
5.1 比设计更简化的列集:单 data JSON blob
实际的 packages/api-headless-cms-sql/src/features/entryTableManager/EntryTableManager.ts 把建表收敛为 9 个列:id、entryId、modelId、tenant、version、isLatest、isPublished、wbyDeleted,外加一个 data TEXT 列,主键为 (tenant, id),索引即文档规划的三条复合索引。也就是说,实现阶段比设计文档更进一步:文档中分散的 values、meta、system、live、location、日期/身份 blob 列,最终统一折叠进 data 列的 JSON.stringify(entry)。
对应的 Mapper(mappers.ts)因此非常轻量:
export const entryToRow = (entry: CmsStorageEntry): IEntryRow => {
return {
id: entry.id,
entryId: entry.entryId,
modelId: entry.modelId,
tenant: entry.tenant,
version: entry.version,
isLatest: entry.isLatest,
isPublished: entry.isPublished,
wbyDeleted: entry.wbyDeleted ?? false,
data: JSON.stringify(entry)
};
};
export const rowToEntry = <T extends CmsEntryValues = CmsEntryValues>(
row: IEntryRow
): CmsEntry<T> => {
return JSON.parse(row.data) as CmsEntry<T>;
};
行类型(types.ts)同样印证了这一点。这与文档"不再有字段列迭代、无哈希列命名"的目标一脉相承,且"模型无关"——entryToRow/rowToEntry 不需要 CMS 模型对象,只是 JSON 序列化/反序列化。
5.2 listEntries 流水线的实际实现
queryHelpers.ts 完整实现了文档描述的流水线,并补充了几个值得注意的工程决策:
MAX_LIST_LIMIT = 1000000:limit为 0 或超过该值时会钳制到上限;- SQL 层面只承担三类固定谓词:
tenant + modelId恒有,其余按where.entryId(修订列表)、where.published === true(已发布)、否则isLatest=true(最新)三选一;wbyDeleted默认追加= false(排除回收站); - 每个条目经
createStorageTransformCallable按模型字段做fromStorage反变换后,交给filter()执行内存表达式树求值; filter()前会从where中剔除published、latest、entryId、wbyDeleted这些已被 SQL 层消费的键,避免重复过滤;- 排序用
sort()(单字段);分页用decodeCursor(after)/encodeCursor(...)(来自@webiny/utils的 base64 整数偏移量 cursor); hasMoreItems = totalCount > start + limit。
filter() 的实现位于 packages/api-headless-cms-storage/src/filtering/filter.ts:先由 createExpressions 把 where 编译为表达式树,再对每条记录做 canUse → matches(支持 negate 取反),并对 AND/OR 条件递归求值;全文搜索(fullTextSearch)复用注册表中的 contains 操作符构造跨字段词条匹配。
5.3 get 与 getUniqueFieldValues 复用 list
- SqlGetEntry.ts 直接调用
listEntries(..., { ...params, limit: 1 })并返回items.shift() || null,与文档"get 即 limit=1 的 list"完全一致。 - SqlGetUniqueFieldValues.ts 以
limit: MAX_LIST_LIMIT走listEntries拿全量条目,再经aggregateUniqueFieldValues(items, field.fieldId)内存聚合——正是文档描述的"加载 + 解析 + 内存聚合"。
5.4 修订同步与批量补丁
mergeEntryLevelMeta(mappers.ts)定义条目级元数据的传播规则:同步所有 *On/*By 字段(createdOn、createdBy 不可变除外),跳过 revision* 前缀的修订级字段。
syncEntryToLatest:当写入的条目并非最新行时,找到最新行并把条目级元数据合并进去(可带extraPatch,如发布时写入live)。patchAllEntryRevisions:一次读取某 entryId 的全部行,在 JS 侧打补丁,然后用一条CASE id WHEN ? THEN ? ... END的批量 UPDATE 原子落库(支持附带columnUpdates)。
这两个 helper 是 update、publish、moveToBin、deleteRevision 等写操作共享的基石,见 queryHelpers.ts。
5.5 标志位翻转的典型实现
以 SqlPublishEntry.ts 为例,发布操作的顺序是:找出旧已发布行 → 逐行 isPublished=false, status="unpublished" → 更新目标行 isPublished=true(保留其 isLatest 状态)→ 通过 syncEntryToLatest 向最新行写入 live = { version }。这与文档表格中的行为一一对应。
SqlCreateEntryRevisionFrom.ts 则展示了"新建修订"的级联:旧最新行 isLatest=false,若新修订即发布态则同时把旧已发布行降级为 unpublished,再插入 isLatest=true(及对应 isPublished)的新行。
5.6 建表并发防护
EntryTableManager.ts 有一个文档未展开但实际实现中很重要的细节:ensureTable() 用 initPromise 记忆化正在进行的建表过程,避免 Promise.all 并发创建条目时多个请求同时执行 hasTable + createTable 竞态;createTable 捕获"表已存在"错误后二次 hasTable 判定为成功,因为该表在所有请求容器间共享。该 Manager 还挂载到 globalThis.__sqlTableManagers,配合 reset() 供测试间清理。
5.7 表名解析与配置
TableNameResolver.ts 实现 {prefix}webiny_cms_{entity}{suffix} 拼接,前缀/后缀经 sanitize(小写化、非 <a href="https://link.gitcode.com/i/7ed72e84625878db90747c790db8e767" target="_blank">a-z0-9_] 字符替换为 _)处理;是否使用共享表由环境变量 WEBINY_SHARED_TABLES === "true" 决定(见 [index.ts 的 TableNameResolverConfig 注册)。
5.8 feature 注册全景
SQL 包的入口 feature HeadlessCmsSqlFeature(packages/api-headless-cms-sql/src/index.ts)依次注册:TableNameResolverConfig + TableNameResolverFeature、ValueFilterFeature(来自 @webiny/db-utils,满足文档的 DI 接线要求)、FilterRegistriesFeature(来自 @webiny/api-headless-cms-storage)、GroupSchemaManagerFeature、ModelSchemaManagerFeature、EntryTableManagerFeature,以及分组/模型/条目的三组存储操作 feature。条目操作共注册 22 个实现(见 operations/entry/feature.ts)。
六、api-headless-cms-ddb 与 db-dynamodb 的同步调整
6.1 @webiny/api-headless-cms-ddb:纯 import 改指向
设计文档明确要求"仅机械改 import,无逻辑变更":
- 删除
src/operations/entry/filtering/—— 改为从@webiny/db-utils/filtering/导入; - 删除
src/plugins/(4 个基类)—— 改为从@webiny/db-utils/plugins/导入; - 删除
src/dynamoDb/path/与src/dynamoDb/transformValue/—— 改为从@webiny/db-utils/path/与@webiny/db-utils/transforms/导入; - 更新包内所有相关 import。
从当前源码看,DDB 后端的条目列表操作已实际改从 @webiny/db-utils 解析 ValueFilterRegistry(见 packages/api-headless-cms-ddb/src/operations/entry/DdbListEntries.ts)。
6.2 @webiny/db-dynamodb:迁出抽象并 re-export
- 把
ValueFilter与ValueFilterRegistry抽象迁至@webiny/db-utils/valueFilter/; - 改指向
db-dynamodb包内的相关 import; - 若外部消费者仍依赖旧导入路径,从
db-dynamodb再导出以保持兼容。
七、不变量(Invariants)
无论重构如何,存储层必须始终满足以下五条不变量(由写操作共同保证):
- 每个 entryId 至多一个
isLatest=true—— 由写操作在插入新最新行前先翻转旧最新行保证; - 每个 entryId 零或一个
isPublished=true—— 由 publish/unpublish 的翻转保证; isPublished=true恒伴随status="published"—— 在同一条 UPDATE 中原子设置;- 任何写入若改变条目级元数据,则传播到所有修订 —— 复用现有 SQL 的
UPDATE WHERE entryId=?模式; - 发布时在所有修订上设置
live字段,unpublish、进回收站、删除已发布修订时清除。
八、测试策略与验证现状
8.1 测试策略
设计文档给出的验证口径是:packages/api-headless-cms/__tests__/ 中既有的 CMS 测试套件通过 WEBINY_STORAGE=sql,ddb 在 SQL 后端上运行;由于存储接口未变、仅内部实现不同,测试无需任何改动。SQLite 内存数据库(better-sqlite3)仍是测试数据库。
8.2 当前验证现状
结合仓库现状文档 packages/api-headless-cms-sql/docs/CURRENT_STATE.md(2026-06-08 记录)可以看到重构的阶段性成果:
- 测试命令:
yarn test:sql packages/api-headless-cms,环境变量WEBINY_STORAGE=sql; - 测试成绩:850 passed / 12 failed / 16 skipped(共 878 项),相比设计文档作为动机提到的 42 个失败,剩余失败已收敛到
import.structure.test.ts与security/basePermissions.test.ts两处与 SQL 存储层无关的既有问题; - 全部 22 个条目存储操作均已实现,分组与模型 CRUD 完成,DI feature 全链路(schema 管理、表名解析、条目表管理)接线完毕;
- 测试基建:
setupAfterEnv.js在beforeEach中 drop 全部 SQLite 表并递增globalThis.__schemaRegistryVersion使缓存失效;Knex 实例在setupFile.js中创建一次,跨所有测试 handler 共享。
九、总结
这份简化设计给出了一个清晰的取舍:在 SQLite 规模下,用"DDB 式固定 schema 单表 + JSON blob + 应用层内存过滤"替代完整的 SQL 查询构造层。收益是双重的——代码量、活动部件与测试失败数同时大幅下降,且 DDB 与 SQL 两个后端共享同一套过滤/排序/值比较代码(@webiny/db-utils 的 valueFilter 抽象 + 共享过滤工具)。约束同样明确:单字段排序、不支持 SQL 级优化、不承诺百万级条目与旧表结构兼容。五条不变量与"存储接口不变"的测试策略,保证了这是一次可以安全落地、持续演进的内部重构。如需深入源码,可从 packages/api-headless-cms-sql/src 与 packages/db-utils/src 的入口继续追踪。