关于 data-layer-footguns
data-layer-footguns 是面向 Kotlin 和 Compose Multiplatform 数据层开发者的 Agent 技能,涵盖 Room、SQL、仓库、缓存和分页中的生产实践陷阱,并提供模式、失败原因和验证步骤;所给 README 详述了数据库与 SQL 内容。
主要功能
- 0126 篇数据层陷阱指南
- 02Room 与 SQL 指引
- 03实践模式与失败原因
- 04针对代码库的验证步骤
- 05相关技能的交叉引用
适用场景
- 01设计缓存或历史记录清理
- 02在 Kotlin Multiplatform 应用中加入 Room
- 03规划长期数据库的迁移
- 04构建导入、分析或时间范围查询
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ maxrave-dev/ kotlin-footguns --skill data-layer-footguns安装后在 Claude Code 中输入/data-layer-footguns确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/bucket-local-time-in-code-not-in-sql.mdbulk-json-import-progress.mdcache-then-network.mdcascading-delete-ordering.mdclean-satellite-at-transition.mdcontinuation-token-pagination-contract.mddatastore-kmp-manager.mdencoded-continuation-tokens-local-sort.mdfirst-ever-not-first-in-window.mdgeneric-paged-db-accumulator.mdimport-format-contract-design.mdinclusive-period-boundaries-and-offset-reset.mdlike-wildcard-escaping-ids.mdlocal-listening-analytics.mdmirror-local-state-to-a-remote-account.mdorder-preserving-section-mapping.mdremote-index-cached-as-rows-with-validator.mdrepository-flow-conventions.mdroom-kmp-setup.mdroom-migrations-at-scale.mdroom-rawquery-readonly-vacuum.mdsearch-over-a-paged-list-queries-the-source.mdsql-not-in-nullable-trap.mdstored-timestamp-is-a-local-wall-clock.mdttl-keyed-json-cache-lenient-decode.mdunbounded-for-shares-capped-for-lists.md详细介绍
数据层易踩的坑:Room、SQL 和仓库
从一个生产级 Kotlin 和 Compose Multiplatform 应用中提炼出的 26 个陷阱,每个都对应 references/ 下的一个简短文件。每个文件都介绍可行模式、陷阱(在实践中失效的具体方式及其原因),以及一个包含可在用户自己的代码树中运行的命令的验证方法部分。
如何使用此索引。 将即将编写的代码或正在排查的症状与以下条目进行匹配:每个条目都标明了主题,以及应当查阅该条目的症状。使用 Read 工具阅读所有可能适用的文件,然后再提出代码或修复方案;每个文件都不超过 150 行。如果验证命令中提到了源项目的路径,请替换为用户代码树中的对应路径。
交叉引用。 文件中以反引号括起、但未在此列出的陷阱名称,位于相邻的技能领域中:使用 glob ../*/references/<name>.md 打开对应文件。
数据库和 SQL
cascading-delete-ordering— 将本地缓存数据库清理到只保留用户实际拥有的内容:先删除容器,再删除叶节点;区分「因状态而保留」的列与真正的垃圾数据;在 DELETE 内重新检查条件,并固定当前正在使用的记录。当「清除缓存」或「清除历史记录」操作顺利完成却没有释放任何空间、意外清除了用户的收藏或下载内容,或正在播放的项目在清理过程中消失时,可参考此文。sql-not-in-nullable-trap— 解释为什么只要子查询结果中实际包含 NULL,x NOT IN (subquery)就会匹配零行却仍报告成功;这对任何可空列都是持续存在的风险。说明如何防护每个此类子查询,以及如何让静默无效的语句变得可检测,而不是毫无察觉。当带有 NOT IN 筛选条件的 DELETE 或 SELECT 没有返回任何内容,而你明明能亲眼看到数据;清理操作「成功」却没有释放空间;或准备对可空列使用任何 NOT IN 之前,可参考此文。like-wildcard-escaping-ids— 使用 LIKE 在文本或 JSON 列中匹配机器生成的 ID:说明 ID 中的_和%为什么会悄无声息地扩大匹配范围,如何通过嵌套的 replace() 和显式的 ESCAPE 字符进行转义,以及为什么必须将 ID 作为带引号的标记匹配,而不是作为普通子串匹配。当清理操作漏掉本应删除的行、查询返回了属于其他 ID 的行,或准备将任何 ID 放入 LIKE 模式之前,可参考此文。room-rawquery-readonly-vacuum— 解释为什么原始查询 DAO 方法运行在只读连接上,因此数据库压缩在此处会因「attempt to write a readonly database」而失败,而同一连接上的检查点操作却能成功并掩盖问题。当批量删除没有释放磁盘空间,或原始语句报告数据库为只读、但你明明以可写方式打开数据库时,可参考此文。clean-satellite-at-transition— 在某个状态结束的确切转换时刻,删除仅用于支持该状态的依赖行,而不是留到之后再批量清理;并说明当变化的只是一个标志时,为什么外键无法替你完成这项工作。当表不断增长且从未删除任何数据、某些行的父项已不再能证明它们有存在理由却仍然留存,或批量清理不得不处理父项已被清除的行时,可参考此文。room-kmp-setup— 为 Android、JVM/桌面端和 iOS 设置一个共享的 Room 数据库:每个平台使用一个 expect/actual 构建器,在注入位置一次性选择捆绑的 SQLite 驱动,并逐个架构审查驱动依赖。当为 Kotlin Multiplatform 模块添加 Room、某个平台在首次连接数据库时失败而其他平台正常,或某个平台能编译但缺少生成的数据库实现时,可参考此文。room-migrations-at-scale— 让 Room 数据库在二十多个架构版本后仍可升级:声明完整的(from,to)迁移边图,而不是假设用户每次只跨一个版本;补齐生成的迁移无法表达的缺口;并在打开回调中以幂等方式重新创建触发器。当从旧版本升级时提示没有迁移路径、升级后的数据库有触发器而全新安装的数据库没有(或反之),或准备向长期运行的应用发布架构变更时,可参考此文。datastore-kmp-manager— 构建多平台偏好设置管理器:每个平台仅通过文件路径生成存储实例;每个键提供一个观察流和一个挂起式 setter;在领域层声明接口,使功能代码无需导入存储库。当为 Kotlin Multiplatform 应用添加共享设置、升级后某项设置读回默认值,或设置页面重新打开前显示过期值时,可参考此文。local-listening-analytics— 完全在设备上构建按用户统计的收听或使用分析:使用仅追加的事件表,以及包含时间戳副本的反规范化贡献者表;设置两个完成度阈值而不是一个;事件中只保存纯 ID,仅在读取时补充标题和封面;所有时间窗口查询都以(start, end)作为参数,让「最近 N 天」保持为参数。当不使用后端而添加「年度回顾」或热门项目页面、时间窗口图表速度缓慢、图表显示的数据莫名少于行数所示的数量,或按贡献者统计的总数与该时段自身的总数对不上时,可参考此文。import-format-contract-design— 规范面向用户的交换文件:说明为什么版本字段可能不如不设版本;拒绝解析后没有任何内容的文件;明确位置对应数组必须等长的规则;指出生产者绝不能输出的旧值;如果生产者是他人发布的格式,则按有意义的键而非位置放置值。当设计导入/导出或备份格式、为已发布的格式编写解析器,或用户报告导入文件后没有任何结果且没有错误时,可参考此文。bulk-json-import-progress— 从用户提供的文件导入数千行数据,同时避免 UI 一片空白或批次中途失败:分块写入并逐块报告进度;在操作数据库前拒绝解析后没有任何内容的数据;筛选传入行,只保留其引用的父项确实存在的行。当构建导入/恢复功能、大文件导入看起来卡住,或单条错误数据导致整个导入中止时,可参考此文。stored-timestamp-is-a-local-wall-clock— 通过 ORM 类型转换器写入的时间戳列,可能把本地墙上时钟编码成 UTC;只有经过该转换器才能精确往返转换。转换器由字段的目标类型决定,因此将投影字段声明为原始数字类型会悄悄跳过转换器,并再次应用时区偏移。说明为什么所有总数仍然正确、为什么某些设备上的误差恰好为零,以及为什么应请求转换器所理解的类型,而不是选择时区。当按小时或星期几统计的数据偏移量恰好是设备自身的时区偏移量,而所有计数和总和都正确;图表显示人们凌晨 3 点最活跃;或准备在手写投影中将存储的时间列声明为数字类型之前,可参考此文。bucket-local-time-in-code-not-in-sql— 在应用代码中通过一次原始扫描按本地小时或本地日期对事件分组,而不是在 SQL 中分组:数据库引擎的本地时间修饰符使用的是进程时区,而非用户的时区;四个本地时间聚合意味着四次扫描,且结果可能彼此不一致。说明哪些聚合适合放在 SQL 中、哪些不适合,以及如何确保单次扫描返回正确结果。当按小时或星期几统计的图表在不同平台或设备与桌面版本之间不一致、准备添加第四个「按日期分组」查询,或准备将日期函数写入查询字符串之前,可参考此文。unbounded-for-shares-capped-for-lists— Top-N 查询适合列表,却不适合计算整体占比:被截掉的尾部会缩小分母和熵归一化因子,从而同时夸大集中度和多样性;因此,同一组数据需要使用边界不同的两个查询。说明为什么结果中看不出截断、为什么无界查询中的 ORDER BY 至关重要,以及哪些情况下无界查询确实安全。当「前 5 项占比」或多样性得分高得不合理、一个分组查询同时用于排行榜和统计数据,或准备将有上限的 DAO 方法用于任何需要除以总数的场景之前,可参考此文。first-ever-not-first-in-window— 统计首次遇到的实体,需要对实体的完整历史取 MIN,并判断最小值是否落在时间窗口内;看似自然的做法是先筛选时间窗口再分组,但这样会把每个实体都当成新的。说明为什么错误查询能通过首次测试、为什么时间窗口条件应放在 HAVING 而非 WHERE、无界扫描需要索引提供什么信息,以及保留期清理如何悄悄重新定义「曾经」。当「本期新增」数值与去重计数完全一致、发现率高得不合理地接近 1,或准备编写任何首次出现查询之前,可参考此文。inclusive-period-boundaries-and-offset-reset— 为步进式时段导航器选择一种边界约定,并始终贯彻到底:以 23:59:59 作为闭合上界会丢失最后一秒中的亚秒部分;而将半开区间边界传给具有闭合边界语义的 BETWEEN,则会重复计算共享时刻。然后在粒度变化时重置步进偏移量,因为某种时长下的 N 个时段之前,并不等于另一种时长下的 N 个时段之前。说明如何将边界限制在当前时刻、如何根据同一个值推导向前导航选项,以及为什么当前时段的总数无法与上一时段直接比较。当切换粒度后时段导航器跳到了错误范围、边界事件缺失或被重复计算,或月初对比结果低得离谱时,可参考此文。
仓库、缓存与分页
repository-flow-conventions— 一张表列出基于本地数据库和远程 API 的 Repository 方法形式:本地读取采用切换到 IO dispatcher 的冷流,远程读取采用包含成功/错误结果的流,写入采用 withContext。新增 Repository 方法、审查形式不一致的方法,或屏幕一直停留在加载状态且日志中没有任何信息时使用。cache-then-network— 通过单个 Repository 流先立即提供已存储的副本,再提供最新数据;只有在没有提供任何内容时才发出错误,因为成功发出数据后再报错,会替换用户正在查看的正常内容。当屏幕每次打开都显示加载指示器,尽管一分钟前显示过相同数据;或短暂的网络故障让原本内容完好的屏幕变成空白时使用。ttl-keyed-json-cache-lenient-decode— 一个用于可能变化的值的小型键控缓存:每个条目都记录获取时间,并根据存活时间常量判断 isStale;整个映射以一个 JSON 字符串存储在键值偏好设置中;解码采用宽容模式并加以封装,因此架构变更会导致缓存未命中,而不是销毁所有条目。无需数据库表即可缓存已解析的封面、查询结果或按键结果时,或新增模型字段后缓存完全失效时使用。generic-paged-db-accumulator— 一个可复用的辅助函数,通过 (limit, offset) 数据访问函数,在有界循环中读取整张表,并在遇到第一个不足一页的结果时停止;同时说明哪些场景适合读取全部数据(导出、备份、批量映射),以及何时这表明你本该使用真正的查询。多个 Repository 都各自手写相同的分页循环,或“读取全部”调用的耗时随表增长而呈超线性增加时使用。continuation-token-pagination-contract— 将返回一页数据和 next-token 的端点建模为 Flow<Resource<Pair<items, token?>>>:token 为 null 表示已到末尾,调用方只保存 token,并在显示任何内容前有界地预取前几页。将不透明游标 API 接入 Repository;列表在一次请求失败后停止加载且无法恢复;或一次触发导致分页请求执行两次时使用。encoded-continuation-tokens-local-sort— 通过在 token 前加上模式标签,并在其后编码游标,让本地排序和打乱顺序的分页沿用远程 API 使用的同一个 token 槽位;对于无法识别的前缀应明确报错,因为静默忽略 token 会让分页器卡在请求中状态,导致列表再也无法加载。一个列表必须支持按服务端游标或本地顺序分页,或本地排序导致分页停止工作且日志中毫无信息时使用。order-preserving-section-mapping— 将分区 API 响应映射为按到达顺序排列的 (title, items) 列表,切勿读取 result[0] 和 result[1] 并将其赋给命名字段。此处记录了登录账户会多出一个置于最前面的分区;任何此类顺序变化都会导致后续所有分区标签错位,并在没有错误提示的情况下丢失最后一个分区。还应假定分区集合及顺序可能因语言区域或地区而异,并截取两份响应进行确认。构建由多个栏目组成的首页 Feed 或浏览屏幕时,或部分用户看到的内容被放在错误标题下时使用。remote-index-cached-as-rows-with-validator— 将大型已发布索引解析为带索引的行后缓存,并使用服务器自己的 ETag 重新验证,使常规新鲜度检查只需传输几百字节。仅在存在行时才重放已存储的验证器;将解析结果为空的 200 响应视为失败;绝不让失败的检查刷新时间戳。从静态主机获取可浏览目录,或缓存的索引变为空且再也没有重新填充时使用。search-over-a-paged-list-queries-the-source— 对分页流进行筛选只能搜索已加载的页面,因此某个条目能否被找到取决于用户恰好滚动了多远。搜索必须查询存储,并作为同级覆盖层渲染,同时不影响分页读取器及其拖动重排、就地移除和滚动位置。涵盖防抖和最小长度门槛、转义为何应放在比查询更高的一层,以及内存筛选何时才正确。搜索找不到明确存在的条目、结果随滚动而变化,或准备将第二个数据源接入分页列表时使用。mirror-local-state-to-a-remote-account— 一个可选开关,用于将本地标记同步到已登录的远程账户。开启时会回填所有已有标记;关闭时停止同步,但有意不撤销已有变更;单项调用采用三态,因此可以区分“未尝试”和“失败”。涵盖先无条件写入本地、两条路径中哪一条可以向用户提示,以及回填为何要顺序执行。当同步标记与账户状态悄然不一致、用户无法区分失败和无操作,或准备将设置开关接入远程写入时使用。
评论
支持 Markdown