Webiny CMS SQL 存储简化方案:用 DDB 式"全量加载 + 内存过滤"重构 `api-headless-cms-sql`

原创2026-10-07 11:04:091,922 阅读
文章标签:CMS后端前端云原生AI 应用

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=?

加载行之后的标准流水线:

  1. 逐行 rowToEntry();
  2. 按字段应用 CMS fromStorage 变换;
  3. 调用 @webiny/db-utils 的 filter()——内存表达式树求值;
  4. totalCount = filteredItems.length;
  5. 调用 @webiny/db-utils 的 sort()——内存排序;
  6. 基于偏移量分页:slice(start, start + limit);cursor 为 base64 编码的整数偏移量(与 DDB 相同);
  7. 返回 { 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 EXISTS
  • features/modelSchemaManager/ — models 表固定 CREATE TABLE IF NOT EXISTS
  • operations/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、applyKeysetCondition
  • features/sqlOperator/ — 全部 14 个操作符 + 注册表
  • features/sqlEntryFilter/ — DefaultFilter、ObjectFilter、RefFilter + 注册表
  • features/entrySchemaManager/ — 动态 schema、ALTER TABLE、columnBuilder
  • features/schemaRegistry/ — 已验证表缓存
  • features/fieldTypeMapper/ — CMS 字段到 SQL 列映射
  • utils/columnName.ts — 哈希列命名
  • utils/parseWhereKey.ts、utils/parseSortField.ts
  • utils/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)

无论重构如何,存储层必须始终满足以下五条不变量(由写操作共同保证):

  1. 每个 entryId 至多一个 isLatest=true —— 由写操作在插入新最新行前先翻转旧最新行保证;
  2. 每个 entryId 零或一个 isPublished=true —— 由 publish/unpublish 的翻转保证;
  3. isPublished=true 恒伴随 status="published" —— 在同一条 UPDATE 中原子设置;
  4. 任何写入若改变条目级元数据,则传播到所有修订 —— 复用现有 SQL 的 UPDATE WHERE entryId=? 模式;
  5. 发布时在所有修订上设置 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 的入口继续追踪。

登录后查看全文
webiny-js