You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tsfile.apache.org by co...@apache.org on 2026/09/02 07:51:18 UTC

(tsfile) 01/01: feat(sqlite): add experimental TsFile hybrid extension

This is an automated email from the ASF dual-hosted git repository.

ColinLeeo pushed a commit to branch experiment-sqlite
in repository https://gitbox.apache.org/repos/asf/tsfile.git

commit 9871a42e38515a0a548a5e035f67231f15be1770
Author: colin <sh...@163.com>
AuthorDate: Wed Sep 2 15:30:11 2026 +0800

    feat(sqlite): add experimental TsFile hybrid extension
---
 cpp/CMakeLists.txt                    |   19 +
 cpp/src/CMakeLists.txt                |    4 +
 cpp/src/sqlite/CMakeLists.txt         |   44 +
 cpp/src/sqlite/README.md              |   74 ++
 cpp/src/sqlite/TECHNICAL_GUIDE.md     |  443 +++++++++
 cpp/src/sqlite/USER_GUIDE.md          |  390 ++++++++
 cpp/src/sqlite/tsfile_sqlite.cc       | 1692 +++++++++++++++++++++++++++++++++
 cpp/test/CMakeLists.txt               |   27 +-
 cpp/test/sqlite/tsfile_sqlite_test.cc |  186 ++++
 9 files changed, 2878 insertions(+), 1 deletion(-)

diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt
index 7ff07a786..ad586fc98 100755
--- a/cpp/CMakeLists.txt
+++ b/cpp/CMakeLists.txt
@@ -229,6 +229,25 @@ endif ()
 option(BUILD_TOOLS "Build the tsfile command-line tools" ON)
 message("cmake using: BUILD_TOOLS=${BUILD_TOOLS}")
 
+option(BUILD_SQLITE_EXTENSION
+        "Build the experimental SQLite + TsFile loadable extension"
+        OFF)
+message("cmake using: BUILD_SQLITE_EXTENSION=${BUILD_SQLITE_EXTENSION}")
+
+if (BUILD_SQLITE_EXTENSION AND WIN32)
+    message(FATAL_ERROR
+            "BUILD_SQLITE_EXTENSION currently supports Linux and macOS only")
+endif ()
+
+if (BUILD_SQLITE_EXTENSION AND NOT TSFILE_BUILD_SHARED)
+    message(FATAL_ERROR
+            "BUILD_SQLITE_EXTENSION requires TSFILE_BUILD_SHARED=ON")
+endif ()
+
+if (BUILD_SQLITE_EXTENSION)
+    find_package(SQLite3 3.31 REQUIRED)
+endif ()
+
 option(ENABLE_ANTLR4 "Enable ANTLR4 runtime" ON)
 message("cmake using: ENABLE_ANTLR4=${ENABLE_ANTLR4}")
 
diff --git a/cpp/src/CMakeLists.txt b/cpp/src/CMakeLists.txt
index 2ab2dd5d6..fa421dfa2 100644
--- a/cpp/src/CMakeLists.txt
+++ b/cpp/src/CMakeLists.txt
@@ -157,3 +157,7 @@ install(TARGETS tsfile
         RUNTIME DESTINATION ${LIBRARY_OUTPUT_PATH}
         LIBRARY DESTINATION ${LIBRARY_OUTPUT_PATH}
         ARCHIVE DESTINATION ${LIBRARY_OUTPUT_PATH})
+
+if (BUILD_SQLITE_EXTENSION)
+    add_subdirectory(sqlite)
+endif ()
diff --git a/cpp/src/sqlite/CMakeLists.txt b/cpp/src/sqlite/CMakeLists.txt
new file mode 100644
index 000000000..b8c6c16fb
--- /dev/null
+++ b/cpp/src/sqlite/CMakeLists.txt
@@ -0,0 +1,44 @@
+#[[
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+]]
+
+find_package(SQLite3 3.31 REQUIRED)
+
+add_library(tsfile_sqlite MODULE tsfile_sqlite.cc)
+target_include_directories(tsfile_sqlite PRIVATE
+        ${CMAKE_SOURCE_DIR}/src
+        ${SQLite3_INCLUDE_DIRS})
+target_link_libraries(tsfile_sqlite PRIVATE tsfile)
+set_target_properties(tsfile_sqlite PROPERTIES
+        PREFIX ""
+        OUTPUT_NAME "tsfile_sqlite"
+        LIBRARY_OUTPUT_DIRECTORY ${LIBRARY_OUTPUT_PATH})
+
+if (APPLE)
+    set_target_properties(tsfile_sqlite PROPERTIES
+            SUFFIX ".dylib"
+            BUILD_RPATH "@loader_path"
+            INSTALL_RPATH "@loader_path")
+else ()
+    set_target_properties(tsfile_sqlite PROPERTIES
+            BUILD_RPATH "$ORIGIN"
+            INSTALL_RPATH "$ORIGIN")
+endif ()
+
+install(TARGETS tsfile_sqlite
+        LIBRARY DESTINATION ${LIBRARY_OUTPUT_PATH})
diff --git a/cpp/src/sqlite/README.md b/cpp/src/sqlite/README.md
new file mode 100644
index 000000000..81c043c0e
--- /dev/null
+++ b/cpp/src/sqlite/README.md
@@ -0,0 +1,74 @@
+<!--
+
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License.  You may obtain a copy of the License at
+
+        http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing,
+    software distributed under the License is distributed on an
+    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+    KIND, either express or implied.  See the License for the
+    specific language governing permissions and limitations
+    under the License.
+
+-->
+
+# SQLite + TsFile extension
+
+`tsfile_sqlite` is an experimental SQLite loadable extension that keeps recent
+rows in SQLite and seals older rows into immutable TsFile table-model segments.
+
+Documentation:
+
+- [User guide](USER_GUIDE.md): build, load, configure, query, seal, deploy, and
+  operate the extension.
+- [Technical guide](TECHNICAL_GUIDE.md): architecture, virtual-table callbacks,
+  storage layout, query planning, and transaction/crash consistency.
+
+Build it with:
+
+```bash
+cmake -S cpp -B cpp/build/sqlite \
+  -DBUILD_SQLITE_EXTENSION=ON -DTSFILE_BUILD_SHARED=ON -DBUILD_TEST=ON
+cmake --build cpp/build/sqlite --target tsfile_sqlite
+```
+
+The extension is emitted next to `libtsfile` in the build `lib` directory.
+Load it and create a hybrid table with:
+
+```sql
+.load ./tsfile_sqlite
+
+CREATE VIRTUAL TABLE sensor USING tsfile_hybrid(
+  directory='/absolute/path/to/segments',
+  timestamp_precision='ms',
+  column='time:TIMESTAMP:TIME',
+  column='device:STRING:TAG',
+  column='temperature:DOUBLE:FIELD'
+);
+```
+
+Seal the half-open historical interval ending at `cutoff` with:
+
+```sql
+INSERT INTO sensor(_tsfile_command, _tsfile_cutoff)
+VALUES ('seal', 1700000000000);
+```
+
+The generated `<table>_data`, `<table>_segments`, and `<table>_config` tables
+are SQLite shadow tables. A seal is synchronous and participates in the
+surrounding SQLite transaction. Data at or above the watermark remains mutable;
+attempts to insert or modify older data return `SQLITE_CONSTRAINT`.
+
+The supported field types are `BOOLEAN`, `INT32`, `INT64`, `FLOAT`, `DOUBLE`,
+`TEXT`, `STRING`, `BLOB`, `DATE`, and `TIMESTAMP`. The first column must be a
+`TIMESTAMP:TIME`, TAG columns must be non-null `STRING`, and the TAG columns
+together with time form the unique key. The directory is created on table
+creation and must be dedicated to that logical table; dropping the virtual
+table does not delete already exported segment files.
diff --git a/cpp/src/sqlite/TECHNICAL_GUIDE.md b/cpp/src/sqlite/TECHNICAL_GUIDE.md
new file mode 100644
index 000000000..e746ca416
--- /dev/null
+++ b/cpp/src/sqlite/TECHNICAL_GUIDE.md
@@ -0,0 +1,443 @@
+<!--
+
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License.  You may obtain a copy of the License at
+
+        http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing,
+    software distributed under the License is distributed on an
+    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+    KIND, either express or implied.  See the License for the
+    specific language governing permissions and limitations
+    under the License.
+
+-->
+
+# tsfile_sqlite 技术手册
+
+本文说明 SQLite + TsFile Hybrid MVP 的设计边界、内部数据结构、SQLite
+virtual table 回调、冷热读写路径以及事务和崩溃一致性。
+
+实现入口是 `tsfile_sqlite.cc`,对 SQLite 注册的模块名为 `tsfile_hybrid`。
+
+## 1. 目标和非目标
+
+### 1.1 目标
+
+- 让应用通过一张 SQLite 逻辑表查询热数据和 TsFile 历史数据;
+- 让近期可变数据保留完整 SQLite 事务和 CRUD 语义;
+- 通过显式时间水位把历史数据导出为标准、不可变 TsFile;
+- 使文件生成、SQLite manifest 更新和热数据删除具备事务一致性;
+- 复用 libtsfile 的 table model、Tablet writer 和 C++ ResultSet reader。
+
+### 1.2 非目标
+
+- 不修改 SQLite pager、B-tree、WAL、VFS 或 SQL parser;
+- 不让 TsFile 成为普通 SQLite database file;
+- 不提供历史 correction/tombstone;
+- 不实现自动 seal、compaction、分布式 manifest 或在线备份协议;
+- 不在 MVP 中承诺跨数据源的物理输出顺序。
+
+因此当前实现是 SQLite loadable extension,而不是 SQLite 内核 fork。若应用不
+希望执行 `.load`,未来可以把相同模块静态链接到定制 SQLite,并通过
+`sqlite3_auto_extension()` 注册;这不会改变本文的数据模型。
+
+## 2. 总体架构
+
+```text
+                         SQLite connection
+                                |
+                    SQL / transaction / planner
+                                |
+                     tsfile_hybrid virtual table
+                      /                       \
+          SQLite shadow hot table        TsFile segment files
+          CRUD, WAL, constraints         immutable table model
+                      \                       /
+                       merged virtual cursor
+```
+
+主要组件职责:
+
+| 组件 | 职责 |
+| --- | --- |
+| SQLite | SQL 执行、事务、WAL/journal、锁、残余谓词、排序和聚合 |
+| `tsfile_sqlite` | virtual table 适配、schema 校验、冷热路由、seal、manifest |
+| `libtsfile` | TsFile table model 编码、文件 footer、查询和 ResultSet |
+
+扩展使用 `sqlite3ext.h` 和 `SQLITE_EXTENSION_INIT1/2` 获取宿主 SQLite API,
+导出标准入口 `sqlite3_extension_init`。入口首次执行时初始化 libtsfile,然后
+通过 `sqlite3_create_module_v2()` 注册 `tsfile_hybrid`。
+
+## 3. 构建集成
+
+根 CMake 增加默认关闭的选项:
+
+```text
+BUILD_SQLITE_EXTENSION=OFF
+```
+
+启用时:
+
+- 拒绝 Windows;
+- 要求 `TSFILE_BUILD_SHARED=ON`;
+- 通过 `find_package(SQLite3 3.31 REQUIRED)` 校验 SQLite;
+- 构建 `MODULE` library `tsfile_sqlite`;
+- 链接同一构建中的 `tsfile` target;
+- Linux 使用 `$ORIGIN`,macOS 使用 `@loader_path` 查找同目录 `libtsfile`;
+- 不给模块添加 Unix 默认的 `lib` 前缀。
+
+测试 target `TsFile_Sqlite_Test` 动态加载刚构建的扩展,避免测试到系统中其他
+版本的插件。
+
+## 4. Virtual table schema
+
+扩展解析重复的 `column=name:TYPE:CATEGORY` 参数,并向 SQLite 声明:
+
+```sql
+CREATE TABLE x(
+  <public columns>,
+  _tsfile_command TEXT HIDDEN,
+  _tsfile_cutoff INTEGER HIDDEN
+);
+```
+
+两个隐藏列只用于把控制命令送入 `xUpdate`,普通 `SELECT *` 不会返回它们。
+
+初始化阶段验证:
+
+- 目录是绝对路径;
+- 时间精度属于 `ms/us/ns`;
+- 第一列且唯一 TIME 列是 `TIMESTAMP:TIME`;
+- 至少一个 `STRING:TAG`;
+- 列名非空、大小写不重复且不占用隐藏列名称。
+
+模块调用:
+
+```c
+sqlite3_vtab_config(db, SQLITE_VTAB_DIRECTONLY);
+sqlite3_vtab_config(db, SQLITE_VTAB_CONSTRAINT_SUPPORT, 1);
+```
+
+`DIRECTONLY` 限制从 view、trigger 等间接 schema 对象调用虚拟表,减少恶意
+数据库文件在连接打开时触发外部文件访问的风险。约束支持标志允许 `xUpdate`
+向 SQLite 返回精确的约束错误。
+
+## 5. Shadow tables
+
+假设逻辑表名为 `sensor`,`xCreate` 在逻辑表所属 schema 中创建:
+
+### 5.1 `sensor_data`
+
+```sql
+CREATE TABLE sensor_data(
+  time INTEGER NOT NULL,
+  device TEXT NOT NULL,
+  temperature REAL,
+  UNIQUE(device, time)
+);
+```
+
+真实列会根据用户 schema 展开。所有 TAG 加 TIME 形成复合唯一键。它是唯一的
+可变数据存储,rowid 由 SQLite 分配。
+
+### 5.2 `sensor_segments`
+
+```sql
+CREATE TABLE sensor_segments(
+  path TEXT PRIMARY KEY,
+  cutoff INTEGER NOT NULL,
+  row_count INTEGER NOT NULL
+);
+```
+
+这是冷段 manifest。`path` 是绝对文件路径,`cutoff` 是生成该段时的新水位,
+`row_count` 用于诊断和后续优化。
+
+### 5.3 `sensor_config`
+
+```sql
+CREATE TABLE sensor_config(
+  id INTEGER PRIMARY KEY CHECK(id = 1),
+  watermark INTEGER NOT NULL,
+  precision TEXT NOT NULL,
+  directory TEXT NOT NULL,
+  schema TEXT NOT NULL
+);
+```
+
+初始 watermark 是 `INT64_MIN`。schema 以列名、TsFile 类型枚举和类别枚举组成
+签名。`xConnect` 重新打开逻辑表时验证 precision、directory 和 schema 签名,
+不一致返回 `SQLITE_CORRUPT`。
+
+`xShadowName` 将 `_data`、`_segments` 和 `_config` 后缀报告给 SQLite。名称通过
+schema 限定和 identifier quoting 生成,因此 attached database 的 shadow
+table 不会错误创建到 `main`。
+
+`xDestroy` 删除三个 shadow table,但刻意不删除 TsFile 文件,避免 `DROP TABLE`
+隐式执行不可恢复的外部文件删除。
+
+## 6. 类型转换
+
+扩展内部用 `Value` 保存精确类型、NULL 标志、数值或带长度的字节串。
+
+写入方向:
+
+```text
+sqlite3_value -> Value -> SQLite hot binding / Tablet value
+```
+
+读取方向:
+
+```text
+SQLite column / C++ ResultSet -> Value -> sqlite3_result_*
+```
+
+TEXT、STRING 和 BLOB 都使用显式指针加长度复制,不把内容当作零结尾 C 字符串,
+因此空字符串、嵌入 `\0` 的 BLOB 和 NULL 可以区分。冷读直接使用 C++
+`ResultSet`,不依赖当前 C wrapper 的字符串接口。
+
+SQLite 是动态类型系统,扩展在 `xUpdate` 中做严格运行时检查。INT32/DATE 会
+额外检查 int32 上下界。BOOLEAN 接受 INTEGER,并归一化为 0 或 1。
+
+## 7. 热区 DML 路径
+
+所有 `INSERT/UPDATE/DELETE` 通过 `xUpdate` 路由:
+
+### INSERT
+
+1. 将 SQLite values 转成 schema 指定的 `Value`;
+2. 验证 TIME 和 TAG 非 NULL;
+3. 验证 `time >= watermark`;
+4. 参数化插入 `_data`;
+5. 由 shadow table 的 UNIQUE 约束检查 `(TAG..., TIME)`。
+
+### UPDATE
+
+1. 冷行的合成 rowid 为负,直接返回 `SQLITE_CONSTRAINT`;
+2. 不允许改变 SQLite rowid;
+3. 校验完整的新行值和新时间;
+4. 按正 rowid 更新 `_data`。
+
+### DELETE
+
+- 负 rowid 返回 `SQLITE_CONSTRAINT`;
+- 正 rowid 从 `_data` 删除。
+
+因为 DML 最终是同一连接上的 SQLite shadow table DML,所以 journal/WAL、事务
+隔离、写锁和回滚仍由 SQLite 提供。
+
+## 8. 查询规划和执行
+
+### 8.1 `xBestIndex`
+
+当前接受以下可用约束:
+
+- TIME 的 `>`、`>=`、`<`、`<=`;
+- 使用 BINARY collation 的 TAG `=`。
+
+对于接受的约束,`argvIndex` 被编码进 `idxStr`。`omit` 保持为 0,要求 SQLite
+对返回行再次执行原 SQL 条件。这样即使 libtsfile 与 SQLite 在边界、类型或
+collation 上存在差异,也不会产生错误结果。
+
+`sqlite3_index_info.colUsed` 用于计算投影列。TIME 和所有下推约束涉及的列会被
+强制加入内部投影,以便执行边界检查和 SQLite 残余检查。
+
+MVP 不设置 `orderByConsumed`,也不下推跨冷热来源的 `LIMIT/OFFSET`。
+
+### 8.2 `xFilter`
+
+`xFilter` 解码约束和投影,然后依次调用:
+
+```text
+read_hot() -> read_cold() -> HybridCursor.rows
+```
+
+当前 cursor 会物化本次查询的所有候选行。`xNext/xEof/xColumn/xRowid` 在该
+内存数组上实现 SQLite cursor 接口。这使 MVP 的合并逻辑简单,但不适合无界
+大结果集;后续应改造成 hot/cold 流式 cursor 和 k-way merge。
+
+### 8.3 热读取
+
+`read_hot` 动态生成参数化 SQL,只选择投影需要的列,并把接受的 TIME/TAG
+约束写入 `_data` 查询。结果按 hot rowid 读取,然后执行一次内部边界和 TAG
+字节比较;返回 SQLite 后仍有 SQLite 的最终残余检查。
+
+### 8.4 冷读取
+
+`read_cold` 从 manifest 按 path 遍历每个 TsFile:
+
+1. 为当前文件创建 `TsFileReader`;
+2. 将 TAG 等值条件组合成 `TagFilterBuilder` AND filter;
+3. 将开闭时间范围转换为 TsFileReader 的闭区间;
+4. 只请求投影所需的 value columns;
+5. 从 C++ ResultSet 恢复精确类型、NULL 和字节长度;
+6. 关闭 ResultSet 和 reader。
+
+当前 manifest 的 cutoff 尚未用于跳过不相交的段;reader 会对每个段应用时间
+范围。FIELD 谓词不进入 TsFile reader,由 SQLite 在结果返回后复核。
+
+### 8.5 Rowid
+
+热行直接暴露 SQLite `_data.rowid`,通常为正值。冷行按
+`(segment ordinal, row ordinal)` 编码为负 int64。负号同时充当不可变标志,
+使 `xUpdate` 可以拒绝冷行修改。
+
+冷 rowid 只对一次遍历有意义,不属于持久存储协议。
+
+## 9. Seal 写路径
+
+控制语句:
+
+```sql
+INSERT INTO sensor(_tsfile_command, _tsfile_cutoff)
+VALUES ('seal', ?);
+```
+
+在 `xUpdate` 中识别命令,cutoff 必须是 SQLite INTEGER 且不能小于当前
+watermark。
+
+### 9.1 段生成
+
+`write_segment()` 完成:
+
+1. 确保绝对目录存在;
+2. 使用表名、进程 ID 和单调计数器生成不冲突的 `.tmp`/`.tsfile` 路径;
+3. 以 `O_CREAT | O_EXCL` 创建临时文件;
+4. 从 `_data` 读取 `[watermark, cutoff)`;
+5. 按所有 TAG、TIME 排序;
+6. 每 1024 行填充一个 table-model `Tablet`;
+7. 通过 `TsFileTableWriter` 写入;
+8. 写入时间精度 property、flush、footer 并 fsync 文件。
+
+若区间没有行,writer 仍会被正确关闭,随后删除空临时文件。调用方仍更新
+watermark,但不插入 manifest 记录。
+
+### 9.2 SQLite 状态变更
+
+非空段写完临时文件后,`seal()` 在当前 SQLite 事务中:
+
+1. 向 `_segments` 插入最终文件路径、cutoff 和 row count;
+2. 从 `_data` 删除 `[watermark, cutoff)`;
+3. 更新 `_config.watermark`;
+4. 把文件记录到当前事务的 `pending` 数组。
+
+这些 shadow table 修改与调用 seal 的外层 SQL 事务相同,不单独提交。
+
+## 10. 事务回调和文件原子性
+
+SQLite 数据库事务不能直接回滚外部文件,因此扩展通过 virtual table transaction
+callbacks 协调两者。
+
+```text
+xBegin
+  |
+xUpdate(seal): write .tmp + update transactional shadow state
+  |
+xSync: validate .tmp -> atomic rename -> fsync directory
+  |
+SQLite commits database/WAL
+  |
+xCommit: forget pending state
+```
+
+### 10.1 `xSync`
+
+SQLite 准备提交时,扩展对每个 pending 文件执行:
+
+1. 使用独立 `TsFileReader` 重新打开临时文件,验证 footer 可读;
+2. 确认最终路径不存在;
+3. 在同一目录执行原子 `rename()`;
+4. `fsync()` 目录,持久化目录项。
+
+只有全部成功,SQLite 才继续提交数据库事务。
+
+### 10.2 `xRollback`
+
+回滚时删除本事务仍存在的 `.tmp`,也删除已经 rename 但数据库事务未提交的
+`.tsfile`,然后从 `_config` 重新加载 watermark。
+
+### 10.3 Savepoint
+
+`xSavepoint` 记录 pending 数组长度。`xRollbackTo` 删除 savepoint 之后创建的
+文件并重新加载事务可见的配置,`xRelease` 移除相应标记。
+
+### 10.4 崩溃窗口
+
+| 崩溃位置 | SQLite 恢复结果 | 文件结果与恢复方式 |
+| --- | --- | --- |
+| 写临时文件过程中 | 热数据和旧 manifest 保留 | 遗留 `.tmp`,下次 seal 清理 |
+| 临时文件完成、rename 前 | 热数据和旧 manifest 保留 | 遗留 `.tmp`,下次 seal 清理 |
+| rename 后、SQLite COMMIT 前 | 热数据和旧 manifest 保留 | 遗留未引用 `.tsfile`,下次 seal 清理 |
+| SQLite COMMIT 后 | 新 manifest、水位和热表删除持久化 | manifest 引用的 `.tsfile` 已完成 rename 和目录 fsync |
+
+`cleanup_orphans()` 在 seal 已取得 SQLite 写事务的上下文中运行。它读取 manifest,
+删除独占目录内未被引用且不属于当前 pending 集合的 `.tmp` 和 `.tsfile`。这也是
+为什么目录独占不是建议,而是正确性前提。
+
+## 11. 并发模型
+
+- 热 DML、manifest 和配置依赖 SQLite 的连接事务与写锁;
+- seal 是同步写操作,不会在后台线程与 SQLite 事务脱离运行;
+- 读事务看到由 SQLite 快照决定的 shadow state;
+- manifest 只在 SQLite 事务提交后对其他连接可见;
+- TsFile 发布使用同目录原子 rename,读取者不会观察到半写文件。
+
+当前设计不提供跨进程独立修改 TsFile 目录的协调机制。目录必须只由拥有 SQLite
+manifest 的扩展实例管理。
+
+## 12. 安全和运维边界
+
+- `DIRECTONLY` 限制间接调用,但加载 native extension 本身仍等价于加载本地
+  代码,应用应只加载可信二进制;
+- 配置要求绝对路径,避免工作目录变化导致数据库重新打开到不同数据集;
+- SQL identifier 和字符串均经过 quoting,运行时值使用 prepared statement;
+- TsFile 文件权限当前为 `0644`,目录创建权限当前为 `0755`,最终仍受进程
+  `umask` 影响;
+- `DROP TABLE` 不清理外部文件;
+- SQLite backup API 不会自动包含 TsFile,备份协议必须覆盖数据库和目录。
+
+## 13. 测试覆盖
+
+`cpp/test/sqlite/tsfile_sqlite_test.cc` 当前覆盖:
+
+- 热数据 CRUD;
+- seal 的 commit 和 rollback;
+- watermark 对旧时间写入的约束;
+- INT32 越界;
+- 冷行 UPDATE/DELETE 拒绝;
+- NULL、空 TEXT 和包含零字节的 BLOB 往返;
+- attached database 的 shadow table 隔离。
+
+实现开发过程中还应持续补充:
+
+- 多连接 WAL 并发;
+- 多次 seal 和多 TAG 查询;
+- 独立 TsFileReader 验证 property/schema/row count;
+- write/close/fsync/rename/commit 各故障点注入;
+- 重启和孤儿文件清理;
+- SQLite 3.45.3 主版本与 3.31 最低版本矩阵;
+- Linux/macOS CI。
+
+## 14. 当前限制与后续方向
+
+当前 MVP 的主要技术债务:
+
+1. 把查询候选行全部物化到内存,应改为流式 merge cursor;
+2. manifest 仅保存 cutoff,尚未记录每段 min/max time、TAG 统计和校验信息;
+3. 没有跨段 compaction,段数增加后每次查询都要打开更多文件;
+4. 没有 correction/tombstone,无法修改冷历史;
+5. 没有后台 seal 调度和限流;
+6. 没有冷段删除与 retention policy;
+7. schema 演进需要新建逻辑表;
+8. 没有一体化在线备份、迁移和目录重定位工具;
+9. 尚未实现 Windows 文件同步和原子发布语义。
+
+若继续演进,建议优先处理流式查询、segment pruning、故障注入测试和一致性备份
+协议,再考虑自动 seal、compaction 和历史修正。
diff --git a/cpp/src/sqlite/USER_GUIDE.md b/cpp/src/sqlite/USER_GUIDE.md
new file mode 100644
index 000000000..4c5075efa
--- /dev/null
+++ b/cpp/src/sqlite/USER_GUIDE.md
@@ -0,0 +1,390 @@
+<!--
+
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License.  You may obtain a copy of the License at
+
+        http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing,
+    software distributed under the License is distributed on an
+    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+    KIND, either express or implied.  See the License for the
+    specific language governing permissions and limitations
+    under the License.
+
+-->
+
+# tsfile_sqlite 用户手册
+
+`tsfile_sqlite` 是一个实验性的 SQLite loadable extension。它提供
+`tsfile_hybrid` 虚拟表,让一张逻辑表同时使用两种物理存储:
+
+- 尚未封存的近期数据保存在 SQLite shadow table 中,支持事务和 CRUD;
+- 已封存的历史数据保存在不可变的 TsFile 段文件中;
+- 应用继续对同一张虚拟表执行 SQL,扩展自动合并冷热数据。
+
+它适合以追加为主、近期数据偶尔需要修正、历史数据可以冻结的时序场景。
+它不是 SQLite 通用表的替代存储引擎,也不会自动把已有 SQLite 表转换为
+TsFile。
+
+## 1. 环境要求
+
+- Linux 或 macOS;
+- SQLite 3.31 或更高版本;
+- 支持加载扩展的 SQLite 构建;
+- CMake 构建时启用共享版 `libtsfile`;
+- TsFile 目录必须使用绝对路径,并由一张逻辑表独占。
+
+当前 MVP 不支持 Windows。
+
+## 2. 构建
+
+在仓库根目录执行:
+
+```bash
+cmake -S cpp -B cpp/build/sqlite \
+  -DBUILD_SQLITE_EXTENSION=ON \
+  -DTSFILE_BUILD_SHARED=ON \
+  -DBUILD_TEST=ON
+
+cmake --build cpp/build/sqlite --target tsfile_sqlite -j
+```
+
+产物位于构建目录的 `lib` 子目录:
+
+- Linux:`cpp/build/sqlite/lib/tsfile_sqlite.so`
+- macOS:`cpp/build/sqlite/lib/tsfile_sqlite.dylib`
+
+扩展依赖同一次构建产生的 `libtsfile`。默认 RPATH 会从扩展所在目录寻找
+`libtsfile`,部署时建议把二者放在同一目录。
+
+运行扩展测试:
+
+```bash
+cmake --build cpp/build/sqlite --target TsFile_Sqlite_Test -j
+ctest --test-dir cpp/build/sqlite/test -R TsFileSqliteTest \
+  --output-on-failure
+```
+
+## 3. 加载扩展
+
+### 3.1 SQLite CLI
+
+```sql
+.load /absolute/path/to/tsfile_sqlite
+```
+
+SQLite CLI 通常会根据平台自动补全 `.so` 或 `.dylib` 后缀。也可以传入完整
+文件名。
+
+### 3.2 C/C++ 应用
+
+```c
+sqlite3_enable_load_extension(db, 1);
+
+char *error = NULL;
+int rc = sqlite3_load_extension(
+    db, "/absolute/path/to/tsfile_sqlite", NULL, &error);
+
+sqlite3_enable_load_extension(db, 0);
+```
+
+应用应在打开数据库连接后、访问 hybrid 表之前加载扩展。生产环境建议加载
+完成后立即关闭动态扩展加载能力。
+
+## 4. 创建逻辑表
+
+```sql
+CREATE VIRTUAL TABLE sensor USING tsfile_hybrid(
+  directory='/var/lib/example/sensor',
+  timestamp_precision='ms',
+  column='time:TIMESTAMP:TIME',
+  column='device:STRING:TAG',
+  column='region:STRING:TAG',
+  column='temperature:DOUBLE:FIELD',
+  column='status:STRING:FIELD',
+  column='payload:BLOB:FIELD'
+);
+```
+
+模块参数如下:
+
+| 参数 | 要求 |
+| --- | --- |
+| `directory` | 必填、绝对路径、由当前逻辑表独占 |
+| `timestamp_precision` | 必填,只能是 `ms`、`us` 或 `ns` |
+| `column` | 可重复,格式为 `名称:类型:类别` |
+
+列定义必须满足:
+
+- 恰好一个 `TIME` 列;
+- `TIME` 必须是第一列,类型必须为 `TIMESTAMP`;
+- 至少一个 `TAG` 列;
+- `TAG` 类型必须为 `STRING`,值不能为 `NULL`;
+- `FIELD` 可以为 `NULL`;
+- 列名大小写不敏感地唯一;
+- `_tsfile_command` 和 `_tsfile_cutoff` 是保留名称。
+
+所有 TAG 列与 TIME 列共同组成唯一键。例如上表的唯一键是:
+
+```text
+(device, region, time)
+```
+
+### 4.1 数据类型映射
+
+| TsFile 类型 | SQLite 表现 | 写入要求 |
+| --- | --- | --- |
+| `BOOLEAN` | INTEGER | 必须传 SQLite INTEGER;0 为假,非 0 为真 |
+| `INT32` | INTEGER | 必须在 int32 范围内 |
+| `INT64` | INTEGER | SQLite int64 |
+| `FLOAT` | REAL | INTEGER 或 REAL |
+| `DOUBLE` | REAL | INTEGER 或 REAL |
+| `TEXT` | TEXT | SQLite TEXT |
+| `STRING` | TEXT | SQLite TEXT |
+| `BLOB` | BLOB | SQLite BLOB,保留长度和二进制零字节 |
+| `DATE` | INTEGER | 必须在 int32 范围内 |
+| `TIMESTAMP` | INTEGER | SQLite int64 |
+
+扩展不会换算时间戳。`timestamp_precision` 仅声明整数时间戳的单位,并写入
+非空 TsFile 段的 `tsfile_sqlite.timestamp_precision` property。
+
+### 4.2 创建后的固定配置
+
+schema、目录和时间精度会记录在配置 shadow table 中。数据库重新打开时,
+扩展会验证这些信息是否与 `CREATE VIRTUAL TABLE` 中保存的参数一致。
+
+当前版本不支持修改 schema、目录或时间精度。需要变更时,应创建一张新的
+逻辑表并迁移数据。
+
+## 5. 写入和修改热数据
+
+普通 DML 的用法与 SQLite 表一致:
+
+```sql
+INSERT INTO sensor(time, device, region, temperature, status)
+VALUES (1700000000000, 'device-1', 'cn-east', 21.5, 'ok');
+
+UPDATE sensor
+SET temperature = 22.0
+WHERE device = 'device-1'
+  AND region = 'cn-east'
+  AND time = 1700000000000;
+
+DELETE FROM sensor
+WHERE device = 'device-1'
+  AND region = 'cn-east'
+  AND time = 1700000000000;
+```
+
+热数据实际写入 `<虚拟表名>_data` shadow table,因此自动使用 SQLite 的
+rollback journal/WAL、锁、唯一约束、事务和 savepoint。
+
+```sql
+BEGIN;
+
+INSERT INTO sensor(time, device, region, temperature)
+VALUES (1700000001000, 'device-1', 'cn-east', 22.1);
+
+UPDATE sensor
+SET status = 'checked'
+WHERE device = 'device-1' AND time = 1700000001000;
+
+COMMIT;
+```
+
+## 6. 查询冷热数据
+
+无论数据位于 SQLite 还是 TsFile,都查询同一张虚拟表:
+
+```sql
+SELECT time, device, temperature, status
+FROM sensor
+WHERE device = 'device-1'
+  AND time >= 1700000000000
+  AND time < 1700086400000
+ORDER BY time;
+```
+
+常规 SQLite SQL 仍然可用,包括 FIELD 条件、表达式、聚合、排序和分页:
+
+```sql
+SELECT device, avg(temperature)
+FROM sensor
+WHERE time >= 1700000000000
+  AND temperature IS NOT NULL
+GROUP BY device;
+```
+
+为了得到更好的 TsFile 扫描效率,查询应尽量包含:
+
+- 整数时间范围;
+- 使用 `BINARY` collation 的 TAG 等值条件;
+- 只选择需要的列。
+
+扩展会把这些条件和投影下推到冷热读取路径。FIELD 条件、排序、聚合、
+`LIMIT/OFFSET` 由 SQLite 在合并结果上处理。扩展不会声称原始输出已经满足
+`ORDER BY`,因此需要稳定顺序时必须显式写出 `ORDER BY`。
+
+所有已下推的约束仍由 SQLite 二次检查,以保证 SQL 结果正确。
+
+## 7. 封存历史数据
+
+使用两个隐藏列发送 `seal` 命令:
+
+```sql
+INSERT INTO sensor(_tsfile_command, _tsfile_cutoff)
+VALUES ('seal', 1700086400000);
+```
+
+`cutoff` 是不包含的上界。上述操作封存:
+
+```text
+旧 watermark <= time < 1700086400000
+```
+
+其中 `time == 1700086400000` 的行仍在热区,可以继续修改。
+
+一次非空 seal 会:
+
+1. 按全部 TAG、TIME 排序读取待封存热数据;
+2. 使用 Tablet 批量写入临时 TsFile;
+3. 校验并将临时文件原子改名为 `.tsfile`;
+4. 在 manifest 中登记文件;
+5. 从热表删除已封存行;
+6. 将 watermark 推进到 cutoff。
+
+如果区间内没有数据,不生成 TsFile,但仍会推进 watermark。cutoff 不能小于
+当前 watermark。
+
+seal 是同步写操作,在完成期间会占用 SQLite 写事务。可以显式把它放入事务:
+
+```sql
+BEGIN IMMEDIATE;
+
+INSERT INTO sensor(_tsfile_command, _tsfile_cutoff)
+VALUES ('seal', 1700086400000);
+
+COMMIT;
+```
+
+如果事务回滚,manifest、watermark 和热数据删除都会回滚,扩展也会删除本次
+事务产生的临时文件或已经改名的段文件。
+
+## 8. Watermark 和冷数据不可变性
+
+watermark 把逻辑时间轴分成两部分:
+
+```text
+time < watermark     冷区:不可修改
+time >= watermark    热区:允许 INSERT/UPDATE/DELETE
+```
+
+以下操作会返回约束错误:
+
+- 插入 `time < watermark` 的行;
+- 把热行的时间更新到 watermark 之前;
+- 更新或删除已经位于 TsFile 的冷行;
+- 写入重复的 `(所有 TAG, TIME)` 唯一键。
+
+当前版本没有 correction 或 tombstone。如果业务必须修正历史数据,需要重建
+逻辑表或在业务层保留单独的修正数据。
+
+## 9. 内部状态与诊断
+
+每张名为 `sensor` 的 hybrid 表拥有三个 SQLite shadow table:
+
+| 表 | 内容 |
+| --- | --- |
+| `sensor_data` | 可变热数据 |
+| `sensor_segments` | TsFile 路径、cutoff 和行数 manifest |
+| `sensor_config` | watermark、精度、目录和 schema 签名 |
+
+可以只读查看它们进行诊断:
+
+```sql
+SELECT watermark, precision, directory FROM sensor_config;
+
+SELECT path, cutoff, row_count
+FROM sensor_segments
+ORDER BY cutoff;
+
+SELECT count(*) AS hot_rows FROM sensor_data;
+```
+
+不要直接修改 shadow table。绕过虚拟表写入会破坏 watermark、manifest 和文件
+之间的一致性。
+
+热行使用 SQLite 的正 rowid;冷行使用扩展生成的负 rowid。冷 rowid 是内部
+实现标识,不应作为跨查询或跨版本稳定的业务主键。
+
+## 10. 数据目录和生命周期
+
+每张逻辑表必须使用独占目录。扩展会在 seal 前清理目录中未被 manifest 引用的
+`.tmp` 和 `.tsfile` 文件,用来恢复进程崩溃后遗留的孤儿文件。因此不要把手工
+创建的 TsFile、其他表的段文件或任何同后缀文件放进该目录。
+
+执行:
+
+```sql
+DROP TABLE sensor;
+```
+
+会删除虚拟表及其三个 shadow table,但不会删除已经导出的 TsFile。删除或归档
+这些文件需要由运维流程显式完成。
+
+## 11. 备份与恢复
+
+完整数据由两部分共同组成:
+
+1. SQLite 数据库文件及其 WAL/journal;
+2. manifest 引用的 TsFile 目录。
+
+只备份 SQLite 文件会丢失冷数据,只备份 TsFile 目录会丢失热数据和 manifest。
+备份工具必须同时捕获两部分的一致视图。MVP 尚未提供在线快照命令,推荐在停止
+写入后备份,或者在应用层协调 SQLite checkpoint/事务与目录快照。
+
+恢复时必须保持配置中记录的绝对目录可用;如果恢复到不同路径,应创建新的逻辑
+表并执行受控迁移,而不是手工修改 `sensor_config`。
+
+## 12. 常见问题
+
+### 加载时报 `not authorized` 或扩展加载被禁用
+
+确认 SQLite 构建允许 loadable extension,并在应用连接上调用
+`sqlite3_enable_load_extension()`。CLI 使用 `.load` 即可。
+
+### 加载时报找不到 `libtsfile`
+
+把当前构建对应的 `libtsfile` 与 `tsfile_sqlite` 放到同一目录,避免混用不同
+版本的库。必要时检查平台动态链接器的搜索路径。
+
+### 创建表时报目录错误
+
+`directory` 必须是绝对路径,父目录必须可创建或可写。连接已有数据库时,该
+目录必须仍然存在。
+
+### seal 后无法更新某些行
+
+这是 watermark 的预期行为。任何 `time < watermark` 的数据已经进入不可变
+冷区。
+
+### 查询没有固定顺序
+
+虚拟表会合并多个来源,但不承诺自然顺序。需要顺序时使用显式 `ORDER BY`。
+
+## 13. MVP 限制
+
+- seal 只能显式、同步执行;
+- 冷数据不可更新或删除;
+- 不支持后台自动封存、compaction 和冷段删除;
+- 不支持原地 schema 演进;
+- 查询游标当前会在内存中汇集冷热结果;
+- 不跨冷热来源下推排序和 `LIMIT/OFFSET`;
+- Windows 尚未支持;
+- 这仍是实验性扩展,接口和内部格式可能继续演进。
diff --git a/cpp/src/sqlite/tsfile_sqlite.cc b/cpp/src/sqlite/tsfile_sqlite.cc
new file mode 100644
index 000000000..8dd5f0c65
--- /dev/null
+++ b/cpp/src/sqlite/tsfile_sqlite.cc
@@ -0,0 +1,1692 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include "sqlite3ext.h"
+SQLITE_EXTENSION_INIT1
+
+#include <dirent.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#include <algorithm>
+#include <cctype>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <limits>
+#include <memory>
+#include <sstream>
+#include <string>
+#include <utility>
+#include <vector>
+
+#ifndef O_DIRECTORY
+#define O_DIRECTORY 0
+#endif
+
+#include "common/schema.h"
+#include "common/tablet.h"
+#include "common/tsfile_common.h"
+#include "file/write_file.h"
+#include "reader/filter/tag_filter.h"
+#include "reader/result_set.h"
+#include "reader/tsfile_reader.h"
+#include "utils/db_utils.h"
+#include "writer/tsfile_table_writer.h"
+
+namespace {
+
+using common::ColumnCategory;
+using common::ColumnSchema;
+using common::TSDataType;
+using storage::ResultSet;
+using storage::TableSchema;
+using storage::Tablet;
+using storage::TsFileReader;
+using storage::TsFileTableWriter;
+using storage::WriteFile;
+
+const int64_t kNoWatermark = std::numeric_limits<int64_t>::min();
+
+struct Column {
+    std::string name;
+    TSDataType type;
+    ColumnCategory category;
+};
+
+struct Value {
+    TSDataType type = common::NULL_TYPE;
+    bool is_null = true;
+    bool b = false;
+    int32_t i32 = 0;
+    int64_t i64 = 0;
+    float f = 0;
+    double d = 0;
+    std::string bytes;
+};
+
+struct Row {
+    sqlite3_int64 rowid = 0;
+    std::vector<Value> values;
+};
+
+struct PendingFile {
+    std::string temporary;
+    std::string final_path;
+    bool renamed = false;
+};
+
+struct HybridTable;
+
+struct HybridCursor : sqlite3_vtab_cursor {
+    HybridTable* table = nullptr;
+    std::vector<Row> rows;
+    size_t pos = 0;
+};
+
+struct HybridTable : sqlite3_vtab {
+    sqlite3* db = nullptr;
+    std::string db_name;
+    std::string table_name;
+    std::string directory;
+    std::string precision;
+    std::vector<Column> columns;
+    int time_index = -1;
+    std::vector<int> tag_indexes;
+    int64_t watermark = kNoWatermark;
+    std::vector<PendingFile> pending;
+    std::vector<size_t> savepoint_marks;
+    uint64_t file_counter = 0;
+};
+
+struct ConstraintSpec {
+    int column;
+    int op;
+    int argv_index;
+    ConstraintSpec(int column_value, int op_value, int argv_value)
+        : column(column_value), op(op_value), argv_index(argv_value) {}
+};
+
+std::string lower(std::string value) {
+    for (char& c : value)
+        c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
+    return value;
+}
+
+std::string quote_id(const std::string& value) {
+    std::string out = "\"";
+    for (char c : value) {
+        if (c == '"')
+            out += "\"\"";
+        else
+            out += c;
+    }
+    out += '"';
+    return out;
+}
+
+std::string quote_sql(const std::string& value) {
+    std::string out = "'";
+    for (char c : value) {
+        if (c == '\'')
+            out += "''";
+        else
+            out += c;
+    }
+    out += '\'';
+    return out;
+}
+
+void copy_bytes(std::string& destination, const void* data, int length) {
+    destination.clear();
+    if (data != nullptr && length > 0)
+        destination.assign(static_cast<const char*>(data),
+                           static_cast<size_t>(length));
+}
+
+void set_error(sqlite3_vtab* vtab, const std::string& message) {
+    if (vtab == nullptr) return;
+    if (vtab->zErrMsg != nullptr) sqlite3_free(vtab->zErrMsg);
+    vtab->zErrMsg = sqlite3_mprintf("%s", message.c_str());
+}
+
+int exec_sql(sqlite3* db, const std::string& sql,
+             sqlite3_vtab* vtab = nullptr) {
+    char* error = nullptr;
+    int rc = sqlite3_exec(db, sql.c_str(), nullptr, nullptr, &error);
+    if (rc != SQLITE_OK && vtab != nullptr) {
+        set_error(vtab, error == nullptr ? "SQLite error" : error);
+    }
+    sqlite3_free(error);
+    return rc;
+}
+
+bool parse_key_value(const char* arg, std::string& key, std::string& value) {
+    if (arg == nullptr) return false;
+    const char* equal = std::strchr(arg, '=');
+    if (equal == nullptr) return false;
+    key.assign(arg, static_cast<size_t>(equal - arg));
+    value.assign(equal + 1);
+    if (value.size() >= 2 && ((value.front() == '\'' && value.back() == '\'') ||
+                              (value.front() == '"' && value.back() == '"'))) {
+        value = value.substr(1, value.size() - 2);
+    }
+    return true;
+}
+
+bool parse_column(const std::string& spec, Column& column, std::string& error) {
+    std::vector<std::string> parts;
+    size_t start = 0;
+    while (start <= spec.size()) {
+        size_t end = spec.find(':', start);
+        parts.push_back(spec.substr(
+            start, end == std::string::npos ? std::string::npos : end - start));
+        if (end == std::string::npos) break;
+        start = end + 1;
+    }
+    if (parts.size() != 3 || parts[0].empty()) {
+        error = "column must be name:TYPE:CATEGORY";
+        return false;
+    }
+    TSDataType type;
+    if (!common::parse_data_type_name(parts[1], type)) {
+        error = "unsupported TsFile column type: " + parts[1];
+        return false;
+    }
+    std::string category = lower(parts[2]);
+    ColumnCategory col_category;
+    if (category == "time")
+        col_category = ColumnCategory::TIME;
+    else if (category == "tag")
+        col_category = ColumnCategory::TAG;
+    else if (category == "field")
+        col_category = ColumnCategory::FIELD;
+    else {
+        error = "column category must be TIME, TAG, or FIELD";
+        return false;
+    }
+    column = {parts[0], type, col_category};
+    return true;
+}
+
+std::string sqlite_type(TSDataType type) {
+    switch (type) {
+        case common::BOOLEAN:
+            return "INTEGER";
+        case common::INT32:
+        case common::INT64:
+        case common::DATE:
+        case common::TIMESTAMP:
+            return "INTEGER";
+        case common::FLOAT:
+        case common::DOUBLE:
+            return "REAL";
+        case common::TEXT:
+        case common::STRING:
+            return "TEXT";
+        case common::BLOB:
+            return "BLOB";
+        default:
+            return "";
+    }
+}
+
+std::string shadow_name(const HybridTable* table, const char* suffix) {
+    return quote_id(table->db_name) + "." +
+           quote_id(table->table_name + suffix);
+}
+
+std::string schema_signature(const HybridTable* table) {
+    std::ostringstream out;
+    for (size_t i = 0; i < table->columns.size(); ++i) {
+        if (i) out << ';';
+        out << table->columns[i].name << ':'
+            << static_cast<int>(table->columns[i].type) << ':'
+            << static_cast<int>(table->columns[i].category);
+    }
+    return out.str();
+}
+
+std::string file_stem(const std::string& table_name) {
+    std::string stem = "tsfile";
+    for (char c : table_name) {
+        if (std::isalnum(static_cast<unsigned char>(c)) || c == '_' || c == '-')
+            stem += c;
+        else
+            stem += '_';
+    }
+    return stem;
+}
+
+bool ensure_directory(const std::string& path) {
+    if (path.empty() || path[0] != '/') return false;
+    size_t pos = 1;
+    while (pos <= path.size()) {
+        pos = path.find('/', pos);
+        std::string part =
+            path.substr(0, pos == std::string::npos ? path.size() : pos);
+        if (!part.empty() && mkdir(part.c_str(), 0755) != 0 && errno != EEXIST)
+            return false;
+        if (pos == std::string::npos) break;
+        ++pos;
+    }
+    struct stat st {};
+    return stat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode);
+}
+
+bool directory_exists(const std::string& path) {
+    struct stat st {};
+    return stat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode);
+}
+
+int sync_directory(const std::string& path) {
+    int fd = open(path.c_str(), O_RDONLY | O_DIRECTORY);
+    if (fd < 0) return SQLITE_IOERR_FSYNC;
+    int rc = fsync(fd) == 0 ? SQLITE_OK : SQLITE_IOERR_FSYNC;
+    close(fd);
+    return rc;
+}
+
+int close_writer_and_sync(WriteFile& write_file, TsFileTableWriter& writer) {
+    // Keep a duplicate descriptor because TsFileTableWriter::close() writes
+    // the footer and closes the original descriptor. This explicit fsync is
+    // independent of libtsfile's process-global sync_on_close setting.
+    int sync_fd = dup(write_file.get_fd());
+    if (sync_fd < 0) return SQLITE_IOERR_FSYNC;
+    int close_rc = writer.close();
+    int sync_rc = (close_rc == common::E_OK && fsync(sync_fd) == 0)
+                      ? SQLITE_OK
+                      : SQLITE_IOERR_FSYNC;
+    int close_fd_rc = close(sync_fd) == 0 ? SQLITE_OK : SQLITE_IOERR;
+    if (close_rc != common::E_OK) return SQLITE_IOERR;
+    if (sync_rc != SQLITE_OK) return sync_rc;
+    return close_fd_rc;
+}
+
+bool value_from_sqlite(sqlite3_value* value, const Column& column, Value& out,
+                       std::string& error) {
+    int type = sqlite3_value_type(value);
+    if (type == SQLITE_NULL) {
+        out.type = column.type;
+        out.is_null = true;
+        return true;
+    }
+    out.type = column.type;
+    out.is_null = false;
+    switch (column.type) {
+        case common::BOOLEAN:
+            if (type != SQLITE_INTEGER) {
+                error = "BOOLEAN requires INTEGER";
+                return false;
+            }
+            out.b = sqlite3_value_int(value) != 0;
+            break;
+        case common::INT32:
+        case common::DATE:
+            if (type != SQLITE_INTEGER) {
+                error = "INT32/DATE requires INTEGER";
+                return false;
+            }
+            {
+                sqlite3_int64 number = sqlite3_value_int64(value);
+                if (number < std::numeric_limits<int32_t>::min() ||
+                    number > std::numeric_limits<int32_t>::max()) {
+                    error = "INT32/DATE value is out of range";
+                    return false;
+                }
+                out.i32 = static_cast<int32_t>(number);
+            }
+            break;
+        case common::INT64:
+        case common::TIMESTAMP:
+            if (type != SQLITE_INTEGER) {
+                error = "INT64/TIMESTAMP requires INTEGER";
+                return false;
+            }
+            out.i64 = sqlite3_value_int64(value);
+            break;
+        case common::FLOAT:
+            if (type != SQLITE_INTEGER && type != SQLITE_FLOAT) {
+                error = "FLOAT requires numeric value";
+                return false;
+            }
+            out.f = static_cast<float>(sqlite3_value_double(value));
+            break;
+        case common::DOUBLE:
+            if (type != SQLITE_INTEGER && type != SQLITE_FLOAT) {
+                error = "DOUBLE requires numeric value";
+                return false;
+            }
+            out.d = sqlite3_value_double(value);
+            break;
+        case common::TEXT:
+        case common::STRING: {
+            if (type != SQLITE_TEXT) {
+                error = "TEXT/STRING requires TEXT";
+                return false;
+            }
+            const unsigned char* text = sqlite3_value_text(value);
+            copy_bytes(out.bytes, text, sqlite3_value_bytes(value));
+            break;
+        }
+        case common::BLOB: {
+            if (type != SQLITE_BLOB) {
+                error = "BLOB requires BLOB";
+                return false;
+            }
+            const void* blob = sqlite3_value_blob(value);
+            copy_bytes(out.bytes, blob, sqlite3_value_bytes(value));
+            break;
+        }
+        default:
+            error = "unsupported column type";
+            return false;
+    }
+    return true;
+}
+
+void bind_value(sqlite3_stmt* stmt, int index, const Value& value) {
+    if (value.is_null) {
+        sqlite3_bind_null(stmt, index);
+        return;
+    }
+    switch (value.type) {
+        case common::BOOLEAN:
+            sqlite3_bind_int(stmt, index, value.b ? 1 : 0);
+            break;
+        case common::INT32:
+        case common::DATE:
+            sqlite3_bind_int(stmt, index, value.i32);
+            break;
+        case common::INT64:
+        case common::TIMESTAMP:
+            sqlite3_bind_int64(stmt, index, value.i64);
+            break;
+        case common::FLOAT:
+            sqlite3_bind_double(stmt, index, value.f);
+            break;
+        case common::DOUBLE:
+            sqlite3_bind_double(stmt, index, value.d);
+            break;
+        case common::TEXT:
+        case common::STRING:
+            sqlite3_bind_text(stmt, index, value.bytes.data(),
+                              static_cast<int>(value.bytes.size()),
+                              SQLITE_TRANSIENT);
+            break;
+        case common::BLOB:
+            sqlite3_bind_blob(stmt, index, value.bytes.data(),
+                              static_cast<int>(value.bytes.size()),
+                              SQLITE_TRANSIENT);
+            break;
+        default:
+            sqlite3_bind_null(stmt, index);
+            break;
+    }
+}
+
+bool row_value_from_sqlite(sqlite3_stmt* stmt, int index, const Column& column,
+                           Value& out) {
+    int type = sqlite3_column_type(stmt, index);
+    out.type = column.type;
+    out.is_null = type == SQLITE_NULL;
+    if (out.is_null) return true;
+    switch (column.type) {
+        case common::BOOLEAN:
+            out.b = sqlite3_column_int(stmt, index) != 0;
+            break;
+        case common::INT32:
+        case common::DATE:
+            out.i32 = sqlite3_column_int(stmt, index);
+            break;
+        case common::INT64:
+        case common::TIMESTAMP:
+            out.i64 = sqlite3_column_int64(stmt, index);
+            break;
+        case common::FLOAT:
+            out.f = static_cast<float>(sqlite3_column_double(stmt, index));
+            break;
+        case common::DOUBLE:
+            out.d = sqlite3_column_double(stmt, index);
+            break;
+        case common::TEXT:
+        case common::STRING: {
+            const unsigned char* text = sqlite3_column_text(stmt, index);
+            copy_bytes(out.bytes, text, sqlite3_column_bytes(stmt, index));
+            break;
+        }
+        case common::BLOB: {
+            const void* blob = sqlite3_column_blob(stmt, index);
+            copy_bytes(out.bytes, blob, sqlite3_column_bytes(stmt, index));
+            break;
+        }
+        default:
+            return false;
+    }
+    return true;
+}
+
+int create_shadow_tables(HybridTable* table, bool insert_config) {
+    std::ostringstream data;
+    data << "CREATE TABLE " << shadow_name(table, "_data") << " (";
+    for (size_t i = 0; i < table->columns.size(); ++i) {
+        if (i) data << ',';
+        data << quote_id(table->columns[i].name) << ' '
+             << sqlite_type(table->columns[i].type);
+        if (table->columns[i].category == ColumnCategory::TIME ||
+            table->columns[i].category == ColumnCategory::TAG)
+            data << " NOT NULL";
+    }
+    data << ", UNIQUE(";
+    bool first = true;
+    for (int index : table->tag_indexes) {
+        if (!first) data << ',';
+        first = false;
+        data << quote_id(table->columns[index].name);
+    }
+    data << ',' << quote_id(table->columns[table->time_index].name) << "))";
+    int rc = exec_sql(table->db, data.str(), table);
+    if (rc != SQLITE_OK) return rc;
+
+    std::ostringstream segments;
+    segments << "CREATE TABLE " << shadow_name(table, "_segments")
+             << " (path TEXT PRIMARY KEY, cutoff INTEGER NOT NULL, row_count "
+                "INTEGER NOT NULL)";
+    rc = exec_sql(table->db, segments.str(), table);
+    if (rc != SQLITE_OK) return rc;
+
+    std::ostringstream config;
+    config << "CREATE TABLE " << shadow_name(table, "_config")
+           << " (id INTEGER PRIMARY KEY CHECK(id=1), watermark INTEGER NOT "
+              "NULL, precision TEXT NOT NULL, directory TEXT NOT NULL, schema "
+              "TEXT NOT NULL)";
+    rc = exec_sql(table->db, config.str(), table);
+    if (rc != SQLITE_OK) return rc;
+    if (insert_config) {
+        std::ostringstream insert;
+        insert << "INSERT INTO " << shadow_name(table, "_config")
+               << "(id,watermark,precision,directory,schema) VALUES(1,"
+               << kNoWatermark << ',' << quote_sql(table->precision) << ','
+               << quote_sql(table->directory) << ','
+               << quote_sql(schema_signature(table)) << ')';
+        rc = exec_sql(table->db, insert.str(), table);
+        if (rc != SQLITE_OK) return rc;
+    }
+    return SQLITE_OK;
+}
+
+int load_config(HybridTable* table) {
+    std::string sql = "SELECT watermark,precision,directory,schema FROM " +
+                      shadow_name(table, "_config") + " WHERE id=1";
+    sqlite3_stmt* stmt = nullptr;
+    int rc = sqlite3_prepare_v2(table->db, sql.c_str(), -1, &stmt, nullptr);
+    if (rc != SQLITE_OK) return rc;
+    rc = sqlite3_step(stmt);
+    if (rc == SQLITE_ROW) {
+        table->watermark = sqlite3_column_int64(stmt, 0);
+        const unsigned char* precision = sqlite3_column_text(stmt, 1);
+        std::string stored_precision =
+            precision == nullptr ? ""
+                                 : reinterpret_cast<const char*>(precision);
+        const unsigned char* directory = sqlite3_column_text(stmt, 2);
+        const unsigned char* schema = sqlite3_column_text(stmt, 3);
+        std::string stored_directory =
+            directory == nullptr ? ""
+                                 : reinterpret_cast<const char*>(directory);
+        std::string stored_schema =
+            schema == nullptr ? "" : reinterpret_cast<const char*>(schema);
+        if (stored_precision != table->precision ||
+            stored_directory != table->directory ||
+            stored_schema != schema_signature(table)) {
+            rc = SQLITE_CORRUPT;
+        } else {
+            table->precision = stored_precision;
+            rc = SQLITE_OK;
+        }
+    } else if (rc == SQLITE_DONE) {
+        rc = SQLITE_CORRUPT;
+    }
+    sqlite3_finalize(stmt);
+    return rc;
+}
+
+int declare_table(HybridTable* table) {
+    std::ostringstream sql;
+    sql << "CREATE TABLE x(";
+    for (size_t i = 0; i < table->columns.size(); ++i) {
+        if (i) sql << ',';
+        sql << quote_id(table->columns[i].name) << ' '
+            << sqlite_type(table->columns[i].type);
+        if (table->columns[i].category == ColumnCategory::TIME ||
+            table->columns[i].category == ColumnCategory::TAG)
+            sql << " NOT NULL";
+    }
+    sql << ",_tsfile_command TEXT HIDDEN,_tsfile_cutoff INTEGER HIDDEN)";
+    return sqlite3_declare_vtab(table->db, sql.str().c_str());
+}
+
+bool parse_args(HybridTable* table, int argc, const char* const* argv,
+                std::string& error) {
+    for (int i = 3; i < argc; ++i) {
+        std::string key, value;
+        if (!parse_key_value(argv[i], key, value)) {
+            error = "module arguments must be key=value";
+            return false;
+        }
+        key = lower(key);
+        if (key == "directory")
+            table->directory = value;
+        else if (key == "timestamp_precision")
+            table->precision = lower(value);
+        else if (key == "column") {
+            Column column;
+            if (!parse_column(value, column, error)) return false;
+            table->columns.push_back(column);
+        } else {
+            error = "unknown tsfile_hybrid option: " + key;
+            return false;
+        }
+    }
+    if (table->directory.empty() || table->directory[0] != '/') {
+        error = "directory must be an absolute path";
+        return false;
+    }
+    if (table->precision != "ms" && table->precision != "us" &&
+        table->precision != "ns") {
+        error = "timestamp_precision must be ms, us, or ns";
+        return false;
+    }
+    if (table->columns.empty() ||
+        table->columns[0].category != ColumnCategory::TIME ||
+        table->columns[0].type != common::TIMESTAMP) {
+        error = "the first column must be TIMESTAMP:TIME";
+        return false;
+    }
+    for (size_t i = 0; i < table->columns.size(); ++i) {
+        if (table->columns[i].name.empty()) {
+            error = "column name cannot be empty";
+            return false;
+        }
+        if (lower(table->columns[i].name) == "_tsfile_command" ||
+            lower(table->columns[i].name) == "_tsfile_cutoff") {
+            error = "column name is reserved by tsfile_hybrid";
+            return false;
+        }
+        for (size_t j = 0; j < i; ++j)
+            if (lower(table->columns[i].name) ==
+                lower(table->columns[j].name)) {
+                error = "duplicate column name";
+                return false;
+            }
+        if (table->columns[i].category == ColumnCategory::TIME) {
+            if (table->time_index != -1) {
+                error = "exactly one TIME column is required";
+                return false;
+            }
+            table->time_index = static_cast<int>(i);
+        } else if (table->columns[i].category == ColumnCategory::TAG) {
+            if (table->columns[i].type != common::STRING) {
+                error = "TAG columns must use STRING";
+                return false;
+            }
+            table->tag_indexes.push_back(static_cast<int>(i));
+        }
+    }
+    if (table->time_index != 0) {
+        error = "TIME column must be the first column";
+        return false;
+    }
+    if (table->tag_indexes.empty()) {
+        error = "at least one TAG column is required";
+        return false;
+    }
+    return true;
+}
+
+int init_table(HybridTable* table, int argc, const char* const* argv,
+               bool create, char** error_message) {
+    table->db_name = argv[1] == nullptr ? "main" : argv[1];
+    table->table_name = argv[2] == nullptr ? "" : argv[2];
+    std::string error;
+    if (!parse_args(table, argc, argv, error)) {
+        if (error_message)
+            *error_message = sqlite3_mprintf("%s", error.c_str());
+        return SQLITE_ERROR;
+    }
+    if (create && !ensure_directory(table->directory)) {
+        if (error_message)
+            *error_message = sqlite3_mprintf("cannot create TsFile directory");
+        return SQLITE_CANTOPEN;
+    }
+    if (!create && !directory_exists(table->directory)) {
+        if (error_message)
+            *error_message = sqlite3_mprintf("TsFile directory does not exist");
+        return SQLITE_CANTOPEN;
+    }
+    if (declare_table(table) != SQLITE_OK) return SQLITE_ERROR;
+    sqlite3_vtab_config(table->db, SQLITE_VTAB_DIRECTONLY);
+    sqlite3_vtab_config(table->db, SQLITE_VTAB_CONSTRAINT_SUPPORT, 1);
+    if (create) {
+        int rc = create_shadow_tables(table, true);
+        if (rc != SQLITE_OK) return rc;
+    } else {
+        int rc = load_config(table);
+        if (rc != SQLITE_OK) return rc;
+    }
+    return SQLITE_OK;
+}
+
+int create_or_connect(sqlite3* db, void* aux, int argc, const char* const* argv,
+                      sqlite3_vtab** vtab, char** error_message, bool create) {
+    (void)aux;
+    std::unique_ptr<HybridTable> table(new HybridTable());
+    table->db = db;
+    int rc = init_table(table.get(), argc, argv, create, error_message);
+    if (rc != SQLITE_OK) return rc;
+    *vtab = table.release();
+    return SQLITE_OK;
+}
+
+int xCreate(sqlite3* db, void* aux, int argc, const char* const* argv,
+            sqlite3_vtab** vtab, char** error) {
+    return create_or_connect(db, aux, argc, argv, vtab, error, true);
+}
+int xConnect(sqlite3* db, void* aux, int argc, const char* const* argv,
+             sqlite3_vtab** vtab, char** error) {
+    return create_or_connect(db, aux, argc, argv, vtab, error, false);
+}
+
+int xDisconnect(sqlite3_vtab* vtab) {
+    HybridTable* table = static_cast<HybridTable*>(vtab);
+    delete table;
+    return SQLITE_OK;
+}
+
+int xDestroy(sqlite3_vtab* vtab) {
+    HybridTable* table = static_cast<HybridTable*>(vtab);
+    int rc =
+        exec_sql(table->db,
+                 "DROP TABLE IF EXISTS " + shadow_name(table, "_data"), table);
+    if (rc == SQLITE_OK)
+        rc = exec_sql(table->db,
+                      "DROP TABLE IF EXISTS " + shadow_name(table, "_segments"),
+                      table);
+    if (rc == SQLITE_OK)
+        rc = exec_sql(table->db,
+                      "DROP TABLE IF EXISTS " + shadow_name(table, "_config"),
+                      table);
+    delete table;
+    return rc;
+}
+
+int xBestIndex(sqlite3_vtab* vtab, sqlite3_index_info* info) {
+    HybridTable* table = static_cast<HybridTable*>(vtab);
+    std::vector<ConstraintSpec> specs;
+    int next_arg = 1;
+    for (int i = 0; i < info->nConstraint; ++i) {
+        const auto& constraint = info->aConstraint[i];
+        if (!constraint.usable || constraint.iColumn < 0 ||
+            constraint.iColumn >= static_cast<int>(table->columns.size()))
+            continue;
+        bool accepted = constraint.iColumn == table->time_index &&
+                        (constraint.op == SQLITE_INDEX_CONSTRAINT_GE ||
+                         constraint.op == SQLITE_INDEX_CONSTRAINT_GT ||
+                         constraint.op == SQLITE_INDEX_CONSTRAINT_LE ||
+                         constraint.op == SQLITE_INDEX_CONSTRAINT_LT);
+        accepted =
+            accepted ||
+            (constraint.op == SQLITE_INDEX_CONSTRAINT_EQ &&
+             std::find(table->tag_indexes.begin(), table->tag_indexes.end(),
+                       constraint.iColumn) != table->tag_indexes.end() &&
+             (sqlite3_vtab_collation(info, i) == nullptr ||
+              lower(sqlite3_vtab_collation(info, i)) == "binary"));
+        if (!accepted) continue;
+        info->aConstraintUsage[i].argvIndex = next_arg++;
+        info->aConstraintUsage[i].omit = 0;
+        specs.push_back({constraint.iColumn, constraint.op,
+                         info->aConstraintUsage[i].argvIndex});
+    }
+    std::vector<int> projection;
+    const sqlite3_uint64 used_columns = info->colUsed;
+    for (size_t i = 0; i < table->columns.size(); ++i) {
+        const bool used = i >= 64 || (used_columns & (1ULL << i)) != 0;
+        if (used) projection.push_back(static_cast<int>(i));
+    }
+    auto add_projection_column = [&projection](int column) {
+        if (std::find(projection.begin(), projection.end(), column) ==
+            projection.end())
+            projection.push_back(column);
+    };
+    // The time column is needed to apply the residual half-open range check;
+    // accepted constraints are also kept available for the second check.
+    add_projection_column(table->time_index);
+    for (const ConstraintSpec& spec : specs) add_projection_column(spec.column);
+    std::sort(projection.begin(), projection.end());
+    if (specs.empty())
+        info->estimatedCost = 1000000.0;
+    else
+        info->estimatedCost = 1000.0;
+    std::ostringstream encoded;
+    for (size_t i = 0; i < specs.size(); ++i) {
+        if (i) encoded << ';';
+        encoded << specs[i].column << ':' << specs[i].op << ':'
+                << specs[i].argv_index;
+    }
+    encoded << "|p:";
+    for (size_t i = 0; i < projection.size(); ++i) {
+        if (i) encoded << ',';
+        encoded << projection[i];
+    }
+    std::string text = encoded.str();
+    if (!text.empty()) {
+        info->idxStr = static_cast<char*>(sqlite3_malloc(text.size() + 1));
+        if (info->idxStr == nullptr) return SQLITE_NOMEM;
+        std::memcpy(info->idxStr, text.c_str(), text.size() + 1);
+        info->needToFreeIdxStr = 1;
+    }
+    return SQLITE_OK;
+}
+
+int parse_specs(const char* encoded, std::vector<ConstraintSpec>& specs,
+                std::vector<int>& projection) {
+    if (encoded == nullptr || *encoded == '\0') return SQLITE_OK;
+    std::string plan(encoded);
+    const size_t projection_marker = plan.find("|p:");
+    std::string spec_text = projection_marker == std::string::npos
+                                ? plan
+                                : plan.substr(0, projection_marker);
+    if (projection_marker != std::string::npos) {
+        std::stringstream projection_input(plan.substr(projection_marker + 3));
+        std::string part;
+        while (std::getline(projection_input, part, ',')) {
+            if (part.empty()) return SQLITE_ERROR;
+            projection.push_back(std::atoi(part.c_str()));
+        }
+    }
+    std::stringstream input(spec_text);
+    std::string token;
+    while (std::getline(input, token, ';')) {
+        std::stringstream item(token);
+        std::string part;
+        std::vector<int> values;
+        while (std::getline(item, part, ':'))
+            values.push_back(std::atoi(part.c_str()));
+        if (values.size() != 3) return SQLITE_ERROR;
+        specs.push_back({values[0], values[1], values[2]});
+    }
+    return SQLITE_OK;
+}
+
+void apply_constraint_bounds(const std::vector<ConstraintSpec>& specs,
+                             sqlite3_value** argv, int64_t& lower_bound,
+                             bool& lower_inclusive, int64_t& upper_bound,
+                             bool& upper_inclusive,
+                             std::vector<std::pair<int, std::string>>& tag_eq) {
+    lower_bound = kNoWatermark;
+    upper_bound = std::numeric_limits<int64_t>::max();
+    lower_inclusive = true;
+    upper_inclusive = true;
+    for (const ConstraintSpec& spec : specs) {
+        sqlite3_value* value = argv[spec.argv_index - 1];
+        if (spec.column == 0 && sqlite3_value_type(value) == SQLITE_INTEGER) {
+            int64_t number = sqlite3_value_int64(value);
+            if (spec.op == SQLITE_INDEX_CONSTRAINT_GE ||
+                spec.op == SQLITE_INDEX_CONSTRAINT_GT) {
+                if (number > lower_bound ||
+                    (number == lower_bound &&
+                     spec.op == SQLITE_INDEX_CONSTRAINT_GE)) {
+                    lower_bound = number;
+                    lower_inclusive = spec.op == SQLITE_INDEX_CONSTRAINT_GE;
+                }
+            } else if (spec.op == SQLITE_INDEX_CONSTRAINT_LE ||
+                       spec.op == SQLITE_INDEX_CONSTRAINT_LT) {
+                if (number < upper_bound ||
+                    (number == upper_bound &&
+                     spec.op == SQLITE_INDEX_CONSTRAINT_LE)) {
+                    upper_bound = number;
+                    upper_inclusive = spec.op == SQLITE_INDEX_CONSTRAINT_LE;
+                }
+            }
+        } else if (spec.op == SQLITE_INDEX_CONSTRAINT_EQ &&
+                   sqlite3_value_type(value) == SQLITE_TEXT) {
+            tag_eq.emplace_back(
+                spec.column,
+                std::string(
+                    reinterpret_cast<const char*>(sqlite3_value_text(value)),
+                    static_cast<size_t>(sqlite3_value_bytes(value))));
+        }
+    }
+}
+
+bool row_matches(const Row& row, const HybridTable* table, int64_t lower,
+                 bool lower_inclusive, int64_t upper, bool upper_inclusive,
+                 const std::vector<std::pair<int, std::string>>& tag_eq) {
+    const Value& time = row.values[table->time_index];
+    int64_t timestamp = time.type == common::TIMESTAMP ? time.i64 : 0;
+    if (timestamp < lower || (!lower_inclusive && timestamp == lower))
+        return false;
+    if (timestamp > upper || (!upper_inclusive && timestamp == upper))
+        return false;
+    for (const auto& condition : tag_eq) {
+        const Value& value = row.values[condition.first];
+        if (value.is_null || value.bytes != condition.second) return false;
+    }
+    return true;
+}
+
+int read_hot(HybridCursor* cursor, const std::vector<ConstraintSpec>& specs,
+             sqlite3_value** argv, int64_t lower, bool lower_inclusive,
+             int64_t upper, bool upper_inclusive,
+             const std::vector<std::pair<int, std::string>>& tag_eq,
+             const std::vector<int>& projection) {
+    HybridTable* table = cursor->table;
+    std::ostringstream sql;
+    sql << "SELECT ";
+    for (size_t i = 0; i < projection.size(); ++i) {
+        const int column = projection[i];
+        if (i) sql << ',';
+        sql << quote_id(table->columns[column].name);
+    }
+    sql << ",rowid FROM " << shadow_name(table, "_data");
+    if (!specs.empty()) {
+        sql << " WHERE ";
+        for (size_t i = 0; i < specs.size(); ++i) {
+            if (i) sql << " AND ";
+            sql << quote_id(table->columns[specs[i].column].name);
+            switch (specs[i].op) {
+                case SQLITE_INDEX_CONSTRAINT_EQ:
+                    sql << "=?";
+                    break;
+                case SQLITE_INDEX_CONSTRAINT_GE:
+                    sql << ">=?";
+                    break;
+                case SQLITE_INDEX_CONSTRAINT_GT:
+                    sql << ">?";
+                    break;
+                case SQLITE_INDEX_CONSTRAINT_LE:
+                    sql << "<=?";
+                    break;
+                case SQLITE_INDEX_CONSTRAINT_LT:
+                    sql << "<?";
+                    break;
+                default:
+                    sql << "=?";
+                    break;
+            }
+        }
+    }
+    sql << " ORDER BY rowid";
+    sqlite3_stmt* stmt = nullptr;
+    int rc =
+        sqlite3_prepare_v2(table->db, sql.str().c_str(), -1, &stmt, nullptr);
+    if (rc != SQLITE_OK) return rc;
+    for (const ConstraintSpec& spec : specs)
+        sqlite3_bind_value(stmt, spec.argv_index, argv[spec.argv_index - 1]);
+    while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
+        Row row;
+        row.rowid =
+            sqlite3_column_int64(stmt, static_cast<int>(projection.size()));
+        row.values.resize(table->columns.size());
+        bool valid = true;
+        for (size_t i = 0; i < projection.size(); ++i) {
+            const int column = projection[i];
+            valid = valid && row_value_from_sqlite(stmt, static_cast<int>(i),
+                                                   table->columns[column],
+                                                   row.values[column]);
+        }
+        if (valid && row_matches(row, table, lower, lower_inclusive, upper,
+                                 upper_inclusive, tag_eq))
+            cursor->rows.push_back(std::move(row));
+    }
+    sqlite3_finalize(stmt);
+    return rc == SQLITE_DONE ? SQLITE_OK : rc;
+}
+
+std::shared_ptr<TableSchema> build_tsfile_schema(const HybridTable* table) {
+    std::vector<ColumnSchema> columns;
+    for (size_t i = 1; i < table->columns.size(); ++i) {
+        const Column& column = table->columns[i];
+        columns.emplace_back(column.name, column.type, column.category);
+    }
+    return std::make_shared<TableSchema>(table->table_name, columns);
+}
+
+sqlite3_int64 cold_rowid(uint64_t segment_ordinal, uint64_t row_ordinal) {
+    // Keep cold rowids negative and deterministic for a segment traversal.
+    // The high/low split leaves enough room for ordinary segment and row
+    // counts while avoiding collisions with SQLite's positive hot rowids.
+    const uint64_t max_code =
+        static_cast<uint64_t>(std::numeric_limits<sqlite3_int64>::max()) - 1;
+    uint64_t code = (segment_ordinal << 32) | (row_ordinal & 0xffffffffULL);
+    if (code > max_code) code = max_code;
+    return -static_cast<sqlite3_int64>(code + 1);
+}
+
+int append_result_row(HybridCursor* cursor, ResultSet* result,
+                      const std::vector<int>& value_indexes,
+                      sqlite3_int64 rowid) {
+    HybridTable* table = cursor->table;
+    Row row;
+    row.rowid = rowid;
+    row.values.resize(table->columns.size());
+    row.values[0].type = common::TIMESTAMP;
+    row.values[0].is_null = result->is_null(1);
+    if (!row.values[0].is_null)
+        row.values[0].i64 = result->get_value<int64_t>(1);
+    for (size_t i = 0; i < value_indexes.size(); ++i) {
+        int table_index = value_indexes[i];
+        uint32_t result_index = static_cast<uint32_t>(i + 2);
+        Value& value = row.values[table_index];
+        value.type = table->columns[table_index].type;
+        value.is_null = result->is_null(result_index);
+        if (value.is_null) continue;
+        switch (value.type) {
+            case common::BOOLEAN:
+                value.b = result->get_value<bool>(result_index);
+                break;
+            case common::INT32:
+                value.i32 = result->get_value<int32_t>(result_index);
+                break;
+            case common::DATE:
+                value.i32 = result->get_value<int32_t>(result_index);
+                break;
+            case common::INT64:
+            case common::TIMESTAMP:
+                value.i64 = result->get_value<int64_t>(result_index);
+                break;
+            case common::FLOAT:
+                value.f = result->get_value<float>(result_index);
+                break;
+            case common::DOUBLE:
+                value.d = result->get_value<double>(result_index);
+                break;
+            case common::TEXT:
+            case common::STRING:
+            case common::BLOB: {
+                common::String* string =
+                    result->get_value<common::String*>(result_index);
+                if (string != nullptr)
+                    copy_bytes(value.bytes, string->buf_, string->len_);
+                break;
+            }
+            default:
+                return SQLITE_ERROR;
+        }
+    }
+    if (row_matches(row, table, kNoWatermark, true,
+                    std::numeric_limits<int64_t>::max(), true, {}))
+        cursor->rows.push_back(std::move(row));
+    return SQLITE_OK;
+}
+
+int read_cold(HybridCursor* cursor, const std::vector<ConstraintSpec>& specs,
+              sqlite3_value** argv, int64_t lower, bool lower_inclusive,
+              int64_t upper, bool upper_inclusive,
+              const std::vector<std::pair<int, std::string>>& tag_eq,
+              const std::vector<int>& projection) {
+    HybridTable* table = cursor->table;
+    std::string sql = "SELECT path FROM " + shadow_name(table, "_segments") +
+                      " ORDER BY path";
+    sqlite3_stmt* stmt = nullptr;
+    int rc = sqlite3_prepare_v2(table->db, sql.c_str(), -1, &stmt, nullptr);
+    if (rc != SQLITE_OK) return rc;
+    std::shared_ptr<TableSchema> schema = build_tsfile_schema(table);
+    std::vector<std::string> value_columns;
+    std::vector<int> value_indexes;
+    for (int column : projection) {
+        if (column == table->time_index) continue;
+        value_columns.push_back(table->columns[column].name);
+        value_indexes.push_back(column);
+    }
+    uint64_t segment_ordinal = 0;
+    while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
+        const unsigned char* path = sqlite3_column_text(stmt, 0);
+        if (path == nullptr) continue;
+        TsFileReader reader;
+        int reader_rc = reader.open(reinterpret_cast<const char*>(path));
+        if (reader_rc != common::E_OK) {
+            sqlite3_finalize(stmt);
+            return SQLITE_IOERR;
+        }
+        storage::Filter* tag_filter = nullptr;
+        storage::TagFilterBuilder tag_builder(schema.get());
+        for (const auto& condition : tag_eq) {
+            storage::Filter* next = tag_builder.eq(
+                table->columns[condition.first].name, condition.second);
+            if (next == nullptr) continue;
+            if (tag_filter == nullptr)
+                tag_filter = next;
+            else
+                tag_filter =
+                    storage::TagFilterBuilder::and_filter(tag_filter, next);
+        }
+        ResultSet* result = nullptr;
+        int64_t query_lower =
+            lower == kNoWatermark ? std::numeric_limits<int64_t>::min() : lower;
+        if (!lower_inclusive &&
+            query_lower != std::numeric_limits<int64_t>::max())
+            ++query_lower;
+        int64_t query_upper = upper;
+        if (!upper_inclusive &&
+            query_upper != std::numeric_limits<int64_t>::min())
+            --query_upper;
+        if (query_lower > query_upper) {
+            delete tag_filter;
+            reader.close();
+            continue;
+        }
+        int query_rc =
+            reader.query(table->table_name, value_columns, query_lower,
+                         query_upper, result, tag_filter);
+        if (query_rc != common::E_OK) {
+            delete tag_filter;
+            reader.close();
+            sqlite3_finalize(stmt);
+            return SQLITE_IOERR;
+        }
+        bool has_next = false;
+        int next_rc = common::E_OK;
+        uint64_t row_ordinal = 0;
+        while ((next_rc = result->next(has_next)) == common::E_OK && has_next) {
+            if (append_result_row(cursor, result, value_indexes,
+                                  cold_rowid(segment_ordinal, row_ordinal++)) !=
+                SQLITE_OK) {
+                reader.destroy_query_data_set(result);
+                delete tag_filter;
+                reader.close();
+                sqlite3_finalize(stmt);
+                return SQLITE_ERROR;
+            }
+        }
+        reader.destroy_query_data_set(result);
+        delete tag_filter;
+        reader.close();
+        if (next_rc != common::E_OK) {
+            sqlite3_finalize(stmt);
+            return SQLITE_IOERR;
+        }
+        ++segment_ordinal;
+    }
+    sqlite3_finalize(stmt);
+    return rc == SQLITE_DONE ? SQLITE_OK : rc;
+}
+
+int xOpen(sqlite3_vtab* vtab, sqlite3_vtab_cursor** cursor) {
+    std::unique_ptr<HybridCursor> result(new HybridCursor());
+    result->table = static_cast<HybridTable*>(vtab);
+    *cursor = result.release();
+    return SQLITE_OK;
+}
+int xClose(sqlite3_vtab_cursor* cursor) {
+    delete static_cast<HybridCursor*>(cursor);
+    return SQLITE_OK;
+}
+
+int xFilter(sqlite3_vtab_cursor* cursor_base, int idx_num, const char* idx_str,
+            int argc, sqlite3_value** argv) {
+    (void)idx_num;
+    (void)argc;
+    HybridCursor* cursor = static_cast<HybridCursor*>(cursor_base);
+    cursor->rows.clear();
+    cursor->pos = 0;
+    std::vector<ConstraintSpec> specs;
+    std::vector<int> projection;
+    int rc = parse_specs(idx_str, specs, projection);
+    if (rc != SQLITE_OK) return rc;
+    if (projection.empty()) {
+        for (size_t i = 0; i < cursor->table->columns.size(); ++i)
+            projection.push_back(static_cast<int>(i));
+    }
+    int64_t lower, upper;
+    bool lower_inclusive, upper_inclusive;
+    std::vector<std::pair<int, std::string>> tag_eq;
+    apply_constraint_bounds(specs, argv, lower, lower_inclusive, upper,
+                            upper_inclusive, tag_eq);
+    rc = read_hot(cursor, specs, argv, lower, lower_inclusive, upper,
+                  upper_inclusive, tag_eq, projection);
+    if (rc == SQLITE_OK)
+        rc = read_cold(cursor, specs, argv, lower, lower_inclusive, upper,
+                       upper_inclusive, tag_eq, projection);
+    return rc;
+}
+int xNext(sqlite3_vtab_cursor* cursor_base) {
+    ++static_cast<HybridCursor*>(cursor_base)->pos;
+    return SQLITE_OK;
+}
+int xEof(sqlite3_vtab_cursor* cursor_base) {
+    HybridCursor* cursor = static_cast<HybridCursor*>(cursor_base);
+    return cursor->pos >= cursor->rows.size();
+}
+
+int xColumn(sqlite3_vtab_cursor* cursor_base, sqlite3_context* context,
+            int column) {
+    HybridCursor* cursor = static_cast<HybridCursor*>(cursor_base);
+    if (column >= static_cast<int>(cursor->table->columns.size())) {
+        sqlite3_result_null(context);
+        return SQLITE_OK;
+    }
+    const Value& value = cursor->rows[cursor->pos].values[column];
+    if (value.is_null) {
+        sqlite3_result_null(context);
+        return SQLITE_OK;
+    }
+    switch (value.type) {
+        case common::BOOLEAN:
+            sqlite3_result_int(context, value.b ? 1 : 0);
+            break;
+        case common::INT32:
+        case common::DATE:
+            sqlite3_result_int(context, value.i32);
+            break;
+        case common::INT64:
+        case common::TIMESTAMP:
+            sqlite3_result_int64(context, value.i64);
+            break;
+        case common::FLOAT:
+            sqlite3_result_double(context, value.f);
+            break;
+        case common::DOUBLE:
+            sqlite3_result_double(context, value.d);
+            break;
+        case common::TEXT:
+        case common::STRING:
+            sqlite3_result_text(context, value.bytes.data(),
+                                static_cast<int>(value.bytes.size()),
+                                SQLITE_TRANSIENT);
+            break;
+        case common::BLOB:
+            sqlite3_result_blob(context, value.bytes.data(),
+                                static_cast<int>(value.bytes.size()),
+                                SQLITE_TRANSIENT);
+            break;
+        default:
+            sqlite3_result_null(context);
+            break;
+    }
+    return SQLITE_OK;
+}
+int xRowid(sqlite3_vtab_cursor* cursor_base, sqlite3_int64* rowid) {
+    HybridCursor* cursor = static_cast<HybridCursor*>(cursor_base);
+    *rowid = cursor->rows[cursor->pos].rowid;
+    return SQLITE_OK;
+}
+
+int check_mutable(HybridTable* table, const std::vector<Value>& values) {
+    const Value& time = values[table->time_index];
+    if (time.is_null || time.type != common::TIMESTAMP ||
+        time.i64 < table->watermark)
+        return SQLITE_CONSTRAINT;
+    for (int index : table->tag_indexes)
+        if (values[index].is_null) return SQLITE_CONSTRAINT_NOTNULL;
+    return SQLITE_OK;
+}
+
+int insert_hot(HybridTable* table, int argc, sqlite3_value** argv,
+               sqlite3_int64* rowid) {
+    std::vector<Value> values(table->columns.size());
+    std::string error;
+    for (size_t i = 0; i < table->columns.size(); ++i) {
+        if (!value_from_sqlite(argv[i + 2], table->columns[i], values[i],
+                               error)) {
+            set_error(table, error);
+            return SQLITE_MISMATCH;
+        }
+    }
+    int rc = check_mutable(table, values);
+    if (rc != SQLITE_OK) {
+        set_error(table,
+                  "row is outside the mutable watermark or violates NOT NULL");
+        return rc;
+    }
+    std::ostringstream sql;
+    sql << "INSERT INTO " << shadow_name(table, "_data") << '(';
+    for (size_t i = 0; i < table->columns.size(); ++i) {
+        if (i) sql << ',';
+        sql << quote_id(table->columns[i].name);
+    }
+    sql << ") VALUES(";
+    for (size_t i = 0; i < table->columns.size(); ++i) {
+        if (i) sql << ',';
+        sql << '?';
+    }
+    sql << ')';
+    sqlite3_stmt* stmt = nullptr;
+    rc = sqlite3_prepare_v2(table->db, sql.str().c_str(), -1, &stmt, nullptr);
+    if (rc == SQLITE_OK) {
+        for (size_t i = 0; i < values.size(); ++i)
+            bind_value(stmt, static_cast<int>(i + 1), values[i]);
+        rc = sqlite3_step(stmt);
+    }
+    if (rc == SQLITE_DONE) {
+        *rowid = sqlite3_last_insert_rowid(table->db);
+        rc = SQLITE_OK;
+    }
+    sqlite3_finalize(stmt);
+    return rc;
+}
+
+int delete_hot(HybridTable* table, sqlite3_int64 rowid) {
+    if (rowid < 0) return SQLITE_CONSTRAINT;
+    std::string sql =
+        "DELETE FROM " + shadow_name(table, "_data") + " WHERE rowid=?";
+    sqlite3_stmt* stmt = nullptr;
+    int rc = sqlite3_prepare_v2(table->db, sql.c_str(), -1, &stmt, nullptr);
+    if (rc == SQLITE_OK) {
+        sqlite3_bind_int64(stmt, 1, rowid);
+        rc = sqlite3_step(stmt);
+    }
+    sqlite3_finalize(stmt);
+    return rc == SQLITE_DONE ? SQLITE_OK : rc;
+}
+
+int update_hot(HybridTable* table, int argc, sqlite3_value** argv,
+               sqlite3_int64 rowid) {
+    if (rowid < 0) return SQLITE_CONSTRAINT;
+    std::vector<Value> values(table->columns.size());
+    std::string error;
+    for (size_t i = 0; i < table->columns.size(); ++i) {
+        if (!value_from_sqlite(argv[i + 2], table->columns[i], values[i],
+                               error)) {
+            set_error(table, error);
+            return SQLITE_MISMATCH;
+        }
+    }
+    int rc = check_mutable(table, values);
+    if (rc != SQLITE_OK) return rc;
+    std::ostringstream sql;
+    sql << "UPDATE " << shadow_name(table, "_data") << " SET ";
+    for (size_t i = 0; i < table->columns.size(); ++i) {
+        if (i) sql << ',';
+        sql << quote_id(table->columns[i].name) << "=?";
+    }
+    sql << " WHERE rowid=?";
+    sqlite3_stmt* stmt = nullptr;
+    rc = sqlite3_prepare_v2(table->db, sql.str().c_str(), -1, &stmt, nullptr);
+    if (rc == SQLITE_OK) {
+        for (size_t i = 0; i < values.size(); ++i)
+            bind_value(stmt, static_cast<int>(i + 1), values[i]);
+        sqlite3_bind_int64(stmt, static_cast<int>(values.size() + 1), rowid);
+        rc = sqlite3_step(stmt);
+    }
+    sqlite3_finalize(stmt);
+    if (rc == SQLITE_DONE) return SQLITE_OK;
+    return rc;
+}
+
+int write_segment(HybridTable* table, int64_t cutoff, PendingFile& file,
+                  sqlite3_int64& row_count) {
+    if (!ensure_directory(table->directory)) return SQLITE_CANTOPEN;
+    std::ostringstream base;
+    do {
+        base.str("");
+        base.clear();
+        base << table->directory << '/' << file_stem(table->table_name) << '-'
+             << static_cast<long long>(getpid()) << '-'
+             << table->file_counter++;
+        file.temporary = base.str() + ".tmp";
+        file.final_path = base.str() + ".tsfile";
+    } while (access(file.temporary.c_str(), F_OK) == 0 ||
+             access(file.final_path.c_str(), F_OK) == 0);
+    WriteFile write_file;
+    int flags = O_WRONLY | O_CREAT | O_EXCL | O_TRUNC;
+    int rc = write_file.create(file.temporary, flags, 0644);
+    if (rc != common::E_OK) return SQLITE_CANTOPEN;
+    std::shared_ptr<TableSchema> schema = build_tsfile_schema(table);
+    TsFileTableWriter writer(&write_file, schema.get());
+    std::vector<std::string> names;
+    std::vector<TSDataType> types;
+    std::vector<ColumnCategory> categories;
+    for (size_t i = 1; i < table->columns.size(); ++i) {
+        names.push_back(table->columns[i].name);
+        types.push_back(table->columns[i].type);
+        categories.push_back(table->columns[i].category);
+    }
+    const int max_rows = 1024;
+    Tablet tablet(table->table_name, names, types, categories, max_rows);
+    std::string sql = "SELECT " + quote_id(table->columns[0].name);
+    for (size_t i = 1; i < table->columns.size(); ++i)
+        sql += "," + quote_id(table->columns[i].name);
+    sql += " FROM " + shadow_name(table, "_data") + " WHERE " +
+           quote_id(table->columns[0].name) + " >= ? AND " +
+           quote_id(table->columns[0].name) + " < ? ORDER BY ";
+    bool first = true;
+    for (int index : table->tag_indexes) {
+        if (!first) sql += ',';
+        first = false;
+        sql += quote_id(table->columns[index].name);
+    }
+    sql += ',' + quote_id(table->columns[0].name);
+    sqlite3_stmt* stmt = nullptr;
+    rc = sqlite3_prepare_v2(table->db, sql.c_str(), -1, &stmt, nullptr);
+    if (rc != SQLITE_OK) return rc;
+    sqlite3_bind_int64(stmt, 1, table->watermark);
+    sqlite3_bind_int64(stmt, 2, cutoff);
+    uint32_t tablet_rows = 0;
+    while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
+        int64_t timestamp = sqlite3_column_int64(stmt, 0);
+        tablet.add_timestamp(tablet_rows, timestamp);
+        for (size_t i = 1; i < table->columns.size(); ++i) {
+            const Column& column = table->columns[i];
+            Value value;
+            if (!row_value_from_sqlite(stmt, static_cast<int>(i), column,
+                                       value)) {
+                sqlite3_finalize(stmt);
+                return SQLITE_ERROR;
+            }
+            if (value.is_null) continue;
+            int add_rc = common::E_OK;
+            switch (column.type) {
+                case common::BOOLEAN:
+                    add_rc = tablet.add_value(
+                        tablet_rows, static_cast<uint32_t>(i - 1), value.b);
+                    break;
+                case common::INT32:
+                case common::DATE:
+                    add_rc = tablet.add_value(
+                        tablet_rows, static_cast<uint32_t>(i - 1), value.i32);
+                    break;
+                case common::INT64:
+                case common::TIMESTAMP:
+                    add_rc = tablet.add_value(
+                        tablet_rows, static_cast<uint32_t>(i - 1), value.i64);
+                    break;
+                case common::FLOAT:
+                    add_rc = tablet.add_value(
+                        tablet_rows, static_cast<uint32_t>(i - 1), value.f);
+                    break;
+                case common::DOUBLE:
+                    add_rc = tablet.add_value(
+                        tablet_rows, static_cast<uint32_t>(i - 1), value.d);
+                    break;
+                case common::TEXT:
+                case common::STRING:
+                case common::BLOB:
+                    add_rc = tablet.add_value(tablet_rows,
+                                              static_cast<uint32_t>(i - 1),
+                                              common::String(value.bytes));
+                    break;
+                default:
+                    add_rc = common::E_TYPE_NOT_SUPPORTED;
+            }
+            if (add_rc != common::E_OK) {
+                sqlite3_finalize(stmt);
+                return SQLITE_ERROR;
+            }
+        }
+        ++tablet_rows;
+        ++row_count;
+        if (tablet_rows == max_rows) {
+            if (writer.write_table(tablet) != common::E_OK) {
+                sqlite3_finalize(stmt);
+                return SQLITE_IOERR;
+            }
+            tablet.reset();
+            tablet_rows = 0;
+        }
+    }
+    sqlite3_finalize(stmt);
+    if (rc != SQLITE_DONE) return rc;
+    if (tablet_rows > 0 && writer.write_table(tablet) != common::E_OK)
+        return SQLITE_IOERR;
+    if (row_count == 0) return close_writer_and_sync(write_file, writer);
+    std::vector<uint8_t> precision(table->precision.begin(),
+                                   table->precision.end());
+    if (writer.add_tsfile_property("tsfile_sqlite.timestamp_precision",
+                                   precision) != common::E_OK)
+        return SQLITE_IOERR;
+    if (writer.flush() != common::E_OK) return SQLITE_IOERR;
+    return close_writer_and_sync(write_file, writer);
+}
+
+void cleanup_orphans(HybridTable* table) {
+    std::vector<std::string> referenced;
+    std::string sql = "SELECT path FROM " + shadow_name(table, "_segments");
+    sqlite3_stmt* stmt = nullptr;
+    if (sqlite3_prepare_v2(table->db, sql.c_str(), -1, &stmt, nullptr) ==
+        SQLITE_OK) {
+        while (sqlite3_step(stmt) == SQLITE_ROW) {
+            const unsigned char* path = sqlite3_column_text(stmt, 0);
+            if (path != nullptr)
+                referenced.emplace_back(reinterpret_cast<const char*>(path));
+        }
+    }
+    sqlite3_finalize(stmt);
+    DIR* directory = opendir(table->directory.c_str());
+    if (directory == nullptr) return;
+    while (dirent* entry = readdir(directory)) {
+        std::string name(entry->d_name);
+        bool candidate =
+            name.size() > 7 && name.substr(name.size() - 7) == ".tsfile";
+        candidate = candidate ||
+                    (name.size() > 4 && name.substr(name.size() - 4) == ".tmp");
+        if (!candidate) continue;
+        std::string path = table->directory + "/" + name;
+        bool pending = false;
+        for (const PendingFile& file : table->pending) {
+            if (path == file.temporary || path == file.final_path) {
+                pending = true;
+                break;
+            }
+        }
+        if (pending) continue;
+        if (std::find(referenced.begin(), referenced.end(), path) ==
+            referenced.end())
+            unlink(path.c_str());
+    }
+    closedir(directory);
+}
+
+int seal(HybridTable* table, int64_t cutoff) {
+    if (cutoff < table->watermark) return SQLITE_CONSTRAINT;
+    cleanup_orphans(table);
+    PendingFile file;
+    sqlite3_int64 row_count = 0;
+    int rc = write_segment(table, cutoff, file, row_count);
+    if (rc != SQLITE_OK) {
+        unlink(file.temporary.c_str());
+        return rc;
+    }
+    if (row_count > 0) {
+        std::string insert = "INSERT INTO " + shadow_name(table, "_segments") +
+                             "(path,cutoff,row_count) VALUES(?,?,?)";
+        sqlite3_stmt* stmt = nullptr;
+        rc = sqlite3_prepare_v2(table->db, insert.c_str(), -1, &stmt, nullptr);
+        if (rc == SQLITE_OK) {
+            sqlite3_bind_text(stmt, 1, file.final_path.c_str(), -1,
+                              SQLITE_TRANSIENT);
+            sqlite3_bind_int64(stmt, 2, cutoff);
+            sqlite3_bind_int64(stmt, 3, row_count);
+            rc = sqlite3_step(stmt);
+        }
+        sqlite3_finalize(stmt);
+        if (rc != SQLITE_DONE) {
+            unlink(file.temporary.c_str());
+            return rc;
+        }
+        std::string del = "DELETE FROM " + shadow_name(table, "_data") +
+                          " WHERE " + quote_id(table->columns[0].name) +
+                          " >= ? AND " + quote_id(table->columns[0].name) +
+                          " < ?";
+        stmt = nullptr;
+        rc = sqlite3_prepare_v2(table->db, del.c_str(), -1, &stmt, nullptr);
+        if (rc == SQLITE_OK) {
+            sqlite3_bind_int64(stmt, 1, table->watermark);
+            sqlite3_bind_int64(stmt, 2, cutoff);
+            rc = sqlite3_step(stmt);
+        }
+        sqlite3_finalize(stmt);
+        if (rc != SQLITE_DONE) {
+            unlink(file.temporary.c_str());
+            return rc;
+        }
+        table->pending.push_back(std::move(file));
+    } else {
+        unlink(file.temporary.c_str());
+    }
+    std::string update = "UPDATE " + shadow_name(table, "_config") +
+                         " SET watermark=? WHERE id=1";
+    sqlite3_stmt* stmt = nullptr;
+    rc = sqlite3_prepare_v2(table->db, update.c_str(), -1, &stmt, nullptr);
+    if (rc == SQLITE_OK) {
+        sqlite3_bind_int64(stmt, 1, cutoff);
+        rc = sqlite3_step(stmt);
+    }
+    sqlite3_finalize(stmt);
+    if (rc != SQLITE_DONE) return rc;
+    table->watermark = cutoff;
+    return SQLITE_OK;
+}
+
+int xUpdate(sqlite3_vtab* vtab, int argc, sqlite3_value** argv,
+            sqlite3_int64* rowid) {
+    HybridTable* table = static_cast<HybridTable*>(vtab);
+    int public_count = static_cast<int>(table->columns.size());
+    if (argc == 1) return delete_hot(table, sqlite3_value_int64(argv[0]));
+    if (argc != public_count + 4) return SQLITE_ERROR;
+    sqlite3_value* command = argv[2 + public_count];
+    if (sqlite3_value_type(command) != SQLITE_NULL) {
+        const unsigned char* text = sqlite3_value_text(command);
+        if (text == nullptr ||
+            lower(reinterpret_cast<const char*>(text)) != "seal") {
+            set_error(vtab, "unknown _tsfile_command");
+            return SQLITE_ERROR;
+        }
+        sqlite3_value* cutoff = argv[3 + public_count];
+        if (sqlite3_value_type(cutoff) != SQLITE_INTEGER) {
+            set_error(vtab, "_tsfile_cutoff must be INTEGER");
+            return SQLITE_MISMATCH;
+        }
+        return seal(table, sqlite3_value_int64(cutoff));
+    }
+    if (sqlite3_value_type(argv[0]) == SQLITE_NULL)
+        return insert_hot(table, argc, argv, rowid);
+    sqlite3_int64 old_rowid = sqlite3_value_int64(argv[0]);
+    if (sqlite3_value_type(argv[1]) == SQLITE_NULL)
+        return delete_hot(table, old_rowid);
+    sqlite3_int64 new_rowid = sqlite3_value_int64(argv[1]);
+    if (new_rowid != old_rowid) return SQLITE_CONSTRAINT;
+    return update_hot(table, argc, argv, old_rowid);
+}
+
+int xBegin(sqlite3_vtab* vtab) {
+    HybridTable* table = static_cast<HybridTable*>(vtab);
+    table->pending.clear();
+    table->savepoint_marks.clear();
+    return SQLITE_OK;
+}
+int xSync(sqlite3_vtab* vtab) {
+    HybridTable* table = static_cast<HybridTable*>(vtab);
+    for (PendingFile& file : table->pending) {
+        if (file.temporary.empty()) continue;
+        TsFileReader reader;
+        int open_rc = reader.open(file.temporary);
+        if (open_rc != common::E_OK) return SQLITE_IOERR;
+        int close_rc = reader.close();
+        if (close_rc != common::E_OK) return SQLITE_IOERR;
+        if (access(file.final_path.c_str(), F_OK) == 0) return SQLITE_IOERR;
+        if (rename(file.temporary.c_str(), file.final_path.c_str()) != 0)
+            return SQLITE_IOERR;
+        file.renamed = true;
+        if (sync_directory(table->directory) != SQLITE_OK)
+            return SQLITE_IOERR_FSYNC;
+    }
+    for (PendingFile& file : table->pending) file.temporary.clear();
+    return SQLITE_OK;
+}
+int xCommit(sqlite3_vtab* vtab) {
+    HybridTable* table = static_cast<HybridTable*>(vtab);
+    table->pending.clear();
+    table->savepoint_marks.clear();
+    return SQLITE_OK;
+}
+int xRollback(sqlite3_vtab* vtab) {
+    HybridTable* table = static_cast<HybridTable*>(vtab);
+    for (const PendingFile& file : table->pending) {
+        if (!file.temporary.empty()) unlink(file.temporary.c_str());
+        if (file.renamed) unlink(file.final_path.c_str());
+    }
+    table->pending.clear();
+    table->savepoint_marks.clear();
+    load_config(table);
+    return SQLITE_OK;
+}
+int xSavepoint(sqlite3_vtab* vtab, int) {
+    HybridTable* table = static_cast<HybridTable*>(vtab);
+    table->savepoint_marks.push_back(table->pending.size());
+    return SQLITE_OK;
+}
+int xRelease(sqlite3_vtab* vtab, int) {
+    HybridTable* table = static_cast<HybridTable*>(vtab);
+    if (!table->savepoint_marks.empty()) table->savepoint_marks.pop_back();
+    return SQLITE_OK;
+}
+int xRollbackTo(sqlite3_vtab* vtab, int) {
+    HybridTable* table = static_cast<HybridTable*>(vtab);
+    if (table->savepoint_marks.empty()) return SQLITE_OK;
+    size_t mark = table->savepoint_marks.back();
+    while (table->pending.size() > mark) {
+        const PendingFile& file = table->pending.back();
+        if (!file.temporary.empty()) unlink(file.temporary.c_str());
+        if (file.renamed) unlink(file.final_path.c_str());
+        table->pending.pop_back();
+    }
+    load_config(table);
+    return SQLITE_OK;
+}
+int xRename(sqlite3_vtab*, const char*) { return SQLITE_CONSTRAINT; }
+int xShadowName(const char* name) {
+    if (name == nullptr) return 0;
+    const char* suffixes[] = {"_data", "_segments", "_config"};
+    for (const char* suffix : suffixes) {
+        size_t length = std::strlen(name), suffix_length = std::strlen(suffix);
+        if (length > suffix_length &&
+            std::strcmp(name + length - suffix_length, suffix) == 0)
+            return 1;
+    }
+    return 0;
+}
+
+const sqlite3_module kModule = {
+    3,          xCreate,  xConnect,    xBestIndex,  xDisconnect,
+    xDestroy,   xOpen,    xClose,      xFilter,     xNext,
+    xEof,       xColumn,  xRowid,      xUpdate,     xBegin,
+    xSync,      xCommit,  xRollback,   nullptr,     xRename,
+    xSavepoint, xRelease, xRollbackTo, xShadowName, nullptr};
+
+}  // namespace
+
+extern "C" int sqlite3_extension_init(sqlite3* db, char** error_message,
+                                      const sqlite3_api_routines* api) {
+    SQLITE_EXTENSION_INIT2(api);
+    static bool initialized = false;
+    if (!initialized) {
+        if (storage::libtsfile_init() != common::E_OK) {
+            if (error_message)
+                *error_message =
+                    sqlite3_mprintf("libtsfile initialization failed");
+            return SQLITE_ERROR;
+        }
+        initialized = true;
+    }
+    return sqlite3_create_module_v2(db, "tsfile_hybrid", &kModule, nullptr,
+                                    nullptr);
+}
diff --git a/cpp/test/CMakeLists.txt b/cpp/test/CMakeLists.txt
index f2d671998..5a843dddb 100644
--- a/cpp/test/CMakeLists.txt
+++ b/cpp/test/CMakeLists.txt
@@ -235,7 +235,7 @@ file(GLOB_RECURSE TEST_SRCS
 if (ENABLE_ANTLR4)
     file(GLOB_RECURSE PARSER_TEST_SRCS "parser/*_test.cc")
     list(APPEND TEST_SRCS ${PARSER_TEST_SRCS})
-endif()
+endif ()
 
 if (ENABLE_SNAPPY)
     file(GLOB_RECURSE SNAPPY_TEST_SRCS "compress/*snappy*_test.cc")
@@ -326,6 +326,31 @@ endif ()
 
 set_target_properties(TsFile_Test PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${LIB_TSFILE_SDK_DIR})
 
+if (BUILD_SQLITE_EXTENSION)
+    add_executable(TsFile_Sqlite_Test sqlite/tsfile_sqlite_test.cc)
+    target_include_directories(TsFile_Sqlite_Test PRIVATE
+            ${VENDORED_GTEST_INCLUDE_DIRS}
+            ${SQLite3_INCLUDE_DIRS})
+    target_link_libraries(TsFile_Sqlite_Test
+            GTest::gtest_main
+            tsfile
+            ${SQLite3_LIBRARIES})
+    add_dependencies(TsFile_Sqlite_Test tsfile_sqlite)
+    target_compile_definitions(TsFile_Sqlite_Test PRIVATE
+            TSFILE_SQLITE_EXTENSION_PATH="$<TARGET_FILE:tsfile_sqlite>")
+    set_target_properties(TsFile_Sqlite_Test PROPERTIES
+            RUNTIME_OUTPUT_DIRECTORY ${LIB_TSFILE_SDK_DIR}
+            SKIP_BUILD_RPATH TRUE)
+    add_test(NAME TsFileSqliteTest COMMAND TsFile_Sqlite_Test)
+    if (APPLE)
+        set_tests_properties(TsFileSqliteTest PROPERTIES
+                ENVIRONMENT "DYLD_LIBRARY_PATH=$<TARGET_FILE_DIR:tsfile>")
+    else ()
+        set_tests_properties(TsFileSqliteTest PROPERTIES
+                ENVIRONMENT "LD_LIBRARY_PATH=$<TARGET_FILE_DIR:tsfile>")
+    endif ()
+endif()
+
 # On Windows, copy tsfile DLL next to the test exe so it can load at runtime
 # (and when gtest_discover_tests runs the exe). Use TARGET_FILE so the path
 # is correct for the current build config (e.g. Release).
diff --git a/cpp/test/sqlite/tsfile_sqlite_test.cc b/cpp/test/sqlite/tsfile_sqlite_test.cc
new file mode 100644
index 000000000..87d0cda2f
--- /dev/null
+++ b/cpp/test/sqlite/tsfile_sqlite_test.cc
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include <gtest/gtest.h>
+#include <sqlite3.h>
+#include <unistd.h>
+
+#include <cstdio>
+#include <string>
+
+namespace {
+
+class TsFileSqliteTest : public ::testing::Test {
+   protected:
+    void SetUp() override {
+        directory_ = "/tmp/tsfile-sqlite-test-" +
+                     std::to_string(static_cast<long long>(getpid()));
+        std::string sql = "SELECT load_extension(" +
+                          quote(TSFILE_SQLITE_EXTENSION_PATH) + ")";
+        ASSERT_EQ(SQLITE_OK, sqlite3_open(":memory:", &db_));
+        char* error = nullptr;
+        ASSERT_EQ(SQLITE_OK, sqlite3_enable_load_extension(db_, 1));
+        ASSERT_EQ(SQLITE_OK,
+                  sqlite3_exec(db_, sql.c_str(), nullptr, nullptr, &error))
+            << (error == nullptr ? "" : error);
+        sqlite3_free(error);
+        ASSERT_EQ(SQLITE_OK,
+                  exec("CREATE VIRTUAL TABLE sensor USING tsfile_hybrid("
+                       "directory='" +
+                       directory_ +
+                       "',"
+                       "timestamp_precision='ms',"
+                       "column='time:TIMESTAMP:TIME',"
+                       "column='device:STRING:TAG',"
+                       "column='temperature:DOUBLE:FIELD')"));
+    }
+
+    void TearDown() override {
+        if (db_ != nullptr) sqlite3_close(db_);
+        db_ = nullptr;
+    }
+
+    int exec(const std::string& sql) {
+        char* error = nullptr;
+        int rc = sqlite3_exec(db_, sql.c_str(), nullptr, nullptr, &error);
+        if (rc != SQLITE_OK) {
+            ADD_FAILURE() << (error == nullptr ? "" : error);
+        }
+        sqlite3_free(error);
+        return rc;
+    }
+
+    int exec_raw(const std::string& sql) {
+        char* error = nullptr;
+        int rc = sqlite3_exec(db_, sql.c_str(), nullptr, nullptr, &error);
+        sqlite3_free(error);
+        return rc;
+    }
+
+    int count(const std::string& sql) {
+        sqlite3_stmt* stmt = nullptr;
+        EXPECT_EQ(SQLITE_OK,
+                  sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr));
+        int result = -1;
+        if (stmt != nullptr && sqlite3_step(stmt) == SQLITE_ROW) {
+            result = sqlite3_column_int(stmt, 0);
+        }
+        sqlite3_finalize(stmt);
+        return result;
+    }
+
+    static std::string quote(const std::string& value) {
+        std::string result = "'";
+        for (char c : value) result += c == '\'' ? "''" : std::string(1, c);
+        result += '\'';
+        return result;
+    }
+
+    sqlite3* db_ = nullptr;
+    std::string directory_;
+};
+
+TEST_F(TsFileSqliteTest, HotCrudAndSealRollback) {
+    ASSERT_EQ(SQLITE_OK, exec("INSERT INTO sensor VALUES(1,'d0',1.5)"));
+    ASSERT_EQ(SQLITE_OK, exec("INSERT INTO sensor VALUES(2,'d0',2.5)"));
+    ASSERT_EQ(2, count("SELECT count(*) FROM sensor"));
+    ASSERT_EQ(SQLITE_OK, exec("BEGIN"));
+    ASSERT_EQ(SQLITE_OK,
+              exec("INSERT INTO sensor(_tsfile_command,_tsfile_cutoff) "
+                   "VALUES('seal',2)"));
+    ASSERT_EQ(SQLITE_OK, exec("ROLLBACK"));
+    ASSERT_EQ(2, count("SELECT count(*) FROM sensor"));
+    ASSERT_EQ(0, count("SELECT count(*) FROM sensor_segments"));
+
+    ASSERT_EQ(SQLITE_OK,
+              exec("INSERT INTO sensor(_tsfile_command,_tsfile_cutoff) "
+                   "VALUES('seal',2)"));
+    ASSERT_EQ(2, count("SELECT count(*) FROM sensor"));
+    ASSERT_EQ(1, count("SELECT count(*) FROM sensor_segments"));
+    ASSERT_EQ(1, count("SELECT count(*) FROM sensor WHERE time=1"));
+    ASSERT_EQ(1, count("SELECT count(*) FROM sensor WHERE time=2"));
+    char* error = nullptr;
+    int rc = sqlite3_exec(db_, "INSERT INTO sensor VALUES(1,'d0',9.0)", nullptr,
+                          nullptr, &error);
+    sqlite3_free(error);
+    EXPECT_EQ(SQLITE_CONSTRAINT, rc);
+}
+
+TEST_F(TsFileSqliteTest, Int32RangeAndColdRowsAreImmutable) {
+    ASSERT_EQ(SQLITE_OK, exec("CREATE VIRTUAL TABLE ints USING tsfile_hybrid("
+                              "directory='" +
+                              directory_ +
+                              "/ints',timestamp_precision='ms',"
+                              "column='time:TIMESTAMP:TIME',"
+                              "column='device:STRING:TAG',"
+                              "column='reading:INT32:FIELD')"));
+    EXPECT_EQ(SQLITE_MISMATCH,
+              exec_raw("INSERT INTO ints VALUES(1,'d0',2147483648)"));
+    ASSERT_EQ(SQLITE_OK, exec("INSERT INTO ints VALUES(1,'d0',42)"));
+    ASSERT_EQ(SQLITE_OK,
+              exec("INSERT INTO ints(_tsfile_command,_tsfile_cutoff) "
+                   "VALUES('seal',2)"));
+    EXPECT_EQ(SQLITE_CONSTRAINT,
+              exec_raw("UPDATE ints SET reading=43 WHERE time=1"));
+    EXPECT_EQ(SQLITE_CONSTRAINT, exec_raw("DELETE FROM ints WHERE time=1"));
+    EXPECT_EQ(1, count("SELECT count(*) FROM ints "
+                       "WHERE time=1 AND reading=42"));
+}
+
+TEST_F(TsFileSqliteTest, NullAndBlobSurviveSeal) {
+    ASSERT_EQ(SQLITE_OK,
+              exec("CREATE VIRTUAL TABLE payload USING tsfile_hybrid("
+                   "directory='" +
+                   directory_ +
+                   "/payload',timestamp_precision='ms',"
+                   "column='time:TIMESTAMP:TIME',"
+                   "column='device:STRING:TAG',"
+                   "column='payload:BLOB:FIELD',"
+                   "column='note:TEXT:FIELD')"));
+    ASSERT_EQ(SQLITE_OK,
+              exec("INSERT INTO payload VALUES(1,'d0',X'000102','')"));
+    ASSERT_EQ(SQLITE_OK, exec("INSERT INTO payload VALUES(2,'d0',NULL,NULL)"));
+    ASSERT_EQ(SQLITE_OK,
+              exec("INSERT INTO payload(_tsfile_command,_tsfile_cutoff) "
+                   "VALUES('seal',3)"));
+    EXPECT_EQ(1, count("SELECT count(*) FROM payload "
+                       "WHERE time=1 AND hex(payload)='000102'"));
+    EXPECT_EQ(1, count("SELECT count(*) FROM payload "
+                       "WHERE time=2 AND payload IS NULL AND note IS NULL"));
+    EXPECT_EQ(1, count("SELECT count(*) FROM payload "
+                       "WHERE time=1 AND length(note)=0"));
+}
+
+TEST_F(TsFileSqliteTest, AttachedDatabaseUsesItsOwnShadowTables) {
+    ASSERT_EQ(SQLITE_OK, exec("ATTACH ':memory:' AS aux"));
+    ASSERT_EQ(SQLITE_OK,
+              exec("CREATE VIRTUAL TABLE aux.attached USING tsfile_hybrid("
+                   "directory='" +
+                   directory_ +
+                   "/attached',timestamp_precision='ms',"
+                   "column='time:TIMESTAMP:TIME',"
+                   "column='device:STRING:TAG',"
+                   "column='reading:INT32:FIELD')"));
+    ASSERT_EQ(SQLITE_OK, exec("INSERT INTO aux.attached VALUES(1,'d0',7)"));
+    EXPECT_EQ(1, count("SELECT count(*) FROM aux.attached_data"));
+    EXPECT_EQ(0, count("SELECT count(*) FROM main.sqlite_master "
+                       "WHERE name='attached_data'"));
+}
+
+}  // namespace