yl32940 1 month ago
parent
commit
e23893a57b

+ 27 - 0
DB/core/testsupport_test.go

@@ -0,0 +1,27 @@
+package core
+
+import (
+	"testing"
+	"time"
+)
+
+// fixOpenDB 开一个临时库: 关掉自动提交 (只在显式 Commit 时落盘), 并注册收尾关闭。
+//
+// 这个助手原本住在 page_leak_fix_test.go 里, 那个文件在上一次提交里被删掉了,
+// 但 newtypes_test.go 仍在用它 —— core 包的测试因此整个编译不过
+// (undefined: fixOpenDB)。这里把它单独放一个文件, 别再跟着别的用例一起被带走。
+func fixOpenDB(t *testing.T) *DB {
+	t.Helper()
+	// t.TempDir 的清理注册在前, 下面的 Close 注册在后 → Close 先执行, 文件不会占用。
+	db, err := Open(t.TempDir() + "/testsupport.db")
+	if err != nil {
+		t.Fatalf("open: %v", err)
+	}
+	db.SetAutoCommitDelay(0) // 0 = 关闭自动提交, 只在显式 Commit 时落盘
+	t.Cleanup(func() {
+		start := time.Now()
+		_ = db.Close()
+		t.Logf("[耗时] Close (含收尾 Commit+fsync): %v", time.Since(start))
+	})
+	return db
+}

+ 72 - 1
Makefile

@@ -4,6 +4,7 @@
 .PHONY: all build test clean run help \
         test-full test-commit test-dirty test-integrity test-mem \
         test-migrate build-migrate \
+        test-demos test-demos-heavy test-all \
         demo-analyzer fmt lint deps tree
 
 # =============================================================================
@@ -88,10 +89,74 @@ build-migrate:
 # Demo 程序
 # =============================================================================
 
+# demo 清单直接取自磁盘 (含 main.go 的目录), 不在这里手抄一份:
+# 手抄的清单一定会漏 —— 新加的 demo 没人想起来往 Makefile 里补一行, 于是它
+# 再也没被跑过, 而且"清单看起来是全的"这件事本身还会让人放心地不去检查。
+DEMO_DIRS := $(patsubst demo/%/main.go,%,$(wildcard demo/*/main.go))
+
+# 重型 demo: 跑得久或吃资源 (千万行压测 / 极端配置), 不进默认的 test-demos。
+# 它们同样必须能跑通, 只是要在需要时显式点名: make test-demos-heavy
+# 顺序从轻到重, 先看到多数结果, 最慢的 bench_10m 放最后。
+HEAVY_DEMOS := hash_bench stress_small_config stress_test stress_extreme_config bench_10m
+
+# 单独跑一个 demo: make demo-unique_index_test
+demo-%:
+	@echo "===== demo/$* ====="
+	cd demo/$* && go run main.go
+
+# 跑一遍全部 demo (排除重型)。
+#
+# 刻意不在第一个失败处退出: 跑一遍只看到第一个坏掉的 demo, 修完再跑又冒出下一个,
+# 全量验证要的是"一次拿到所有坏消息"。日志留在 /tmp, 失败时能直接查。
+test-demos:
+	@echo ""
+	@echo "=========================================="
+	@echo "  Demo 全量验证 (共 $(words $(filter-out $(HEAVY_DEMOS),$(DEMO_DIRS))) 个)"
+	@echo "=========================================="
+	@ok=0; fail=0; \
+	for d in $(filter-out $(HEAVY_DEMOS),$(DEMO_DIRS)); do \
+		printf "  %-26s" "$$d"; \
+		if ( cd demo/$$d && go run main.go ) > /tmp/tsdb2_demo_$$d.log 2>&1; then \
+			echo "✅ 通过"; ok=$$((ok+1)); \
+		else \
+			echo "❌ 失败 (日志: /tmp/tsdb2_demo_$$d.log)"; fail=$$((fail+1)); \
+		fi; \
+	done; \
+	echo ""; \
+	echo "  结果: 通过 $$ok, 失败 $$fail"; \
+	test $$fail -eq 0
+
+# 重型 demo (单独跑)
+test-demos-heavy:
+	@echo ""
+	@echo "=========================================="
+	@echo "  重型 Demo (共 $(words $(HEAVY_DEMOS)) 个)"
+	@echo "=========================================="
+	@ok=0; fail=0; \
+	for d in $(HEAVY_DEMOS); do \
+		printf "  %-26s" "$$d"; \
+		if ( cd demo/$$d && go run main.go ) > /tmp/tsdb2_demo_$$d.log 2>&1; then \
+			echo "✅ 通过"; ok=$$((ok+1)); \
+		else \
+			echo "❌ 失败 (日志: /tmp/tsdb2_demo_$$d.log)"; fail=$$((fail+1)); \
+		fi; \
+	done; \
+	echo ""; \
+	echo "  结果: 通过 $$ok, 失败 $$fail"; \
+	test $$fail -eq 0
+
+# 一键全量: 构建 + 迁移往返 + 全部 demo
+test-all: build test-migrate test-demos
+	@echo ""
+	@echo "✅ 全量验证完成 (重型 demo 另跑: make test-demos-heavy)"
+
 # 运行 Analyzer (数据库可视化工具)
+#
+# 用 `go run .` 而不是 `go run main.go`: analyzer 是多文件包 (store/pagemap/
+# audit/indexcheck...), 只喂一个文件会报一片 undefined。
 demo-analyzer:
 	@echo "=== 启动 TsDB2 Analyzer ==="
-	cd analyzer && go run main.go
+	cd analyzer && go run .
 
 # =============================================================================
 # 开发工具
@@ -151,6 +216,8 @@ clean:
 	@rm -f *.db
 	@rm -f demo/*/*.exe
 	@rm -f vrtxsql vrtxsql.exe
+	@rm -f analyzer/analyzer analyzer/analyzer.exe
+	@rm -f /tmp/tsdb2_demo_*.log
 	@echo "✅ 清理完成"
 
 # =============================================================================
@@ -179,6 +246,9 @@ help:
 	@echo "  make build-migrate - 构建 vrtxsql 命令行工具"
 	@echo ""
 	@echo "🎨 Demo 程序"
+	@echo "  make test-demos    - 跑一遍全部 demo (排除重型, 逐个报通过/失败)"
+	@echo "  make test-demos-heavy - 重型 demo (千万行压测 / 极端配置)"
+	@echo "  make demo-<name>   - 单独跑一个 demo (如 make demo-unique_index_test)"
 	@echo "  make demo-analyzer - 启动数据库可视化工具"
 	@echo ""
 	@echo "🛠️  开发工具"
@@ -189,6 +259,7 @@ help:
 	@echo ""
 	@echo "📖 快速开始"
 	@echo "  make build && make test    # 构建并运行所有测试"
+	@echo "  make test-all              # 构建 + 迁移往返 + 全部 demo (一键全量)"
 	@echo "  make demo-analyzer         # 启动可视化工具"
 	@echo ""
 	@echo "💡 提示"

+ 3 - 2
README.md

@@ -969,10 +969,11 @@ fmt.Println(rep.String())                 // 每张表的行数 / 列数 / 索
 SQL 表达不了 VRTX 的这些细节,所以导出时会在 SQL 侧额外建一张隐藏表 `__vrtx_meta` 记着:
 
 - 每列的**定长档位**(1/2/4/8/16/32/64/128/256 / Float64 / Int8/16/32/64 / 变长)——否则 `INT` 回来时无法区分 4 字节还是 8 字节,也无从分辨有符号档与无符号档;
+- 每列的**可空性**(`NULL` / `NOT NULL`)与**列级唯一声明**——SQL 的列级 `UNIQUE` 在读回时只剩一条自动索引,列上"声明过唯一"这半信息只有这里记得住;
 - 行存储索引的**类型**(primary / time)与**名字**(`pk_<表名>`);
-- 二级索引的**类型**(B+ 树 / Hash / 复合)与分量列顺序。
+- 二级索引的**类型**(B+ 树 / Hash / 复合)、分量列顺序与**唯一标记**(`UNIQUE INDEX` ⇄ `IndexFlagUnique`)。
 
-这样 `VRTX → SQLite → VRTX` 拿回来的库和原库**逐列逐行等价**(`migrate/roundtrip_test.go` 里有符号 / 无符号各档整数 + 浮点 + 变长列 + Hash/B+ 树索引 + `uint64` 边界值全部比对通过)。
+这样 `VRTX → SQLite → VRTX` 拿回来的库和原库**逐列逐行等价**(`migrate/roundtrip_test.go` 里有符号 / 无符号各档整数 + 浮点 + 变长列 + Hash/B+ 树索引 + `uint64` 边界值 + 可空列的 `NULL` 值 + 唯一索引全部比对通过)。
 
 不带 `__vrtx_meta` 的 SQL 库(外部既有库)也能导入:类型按 SQL 声明就近取档(`INTEGER → Int64`、`VARCHAR(32) → 32byte`、`REAL → Float64`、`BLOB → Dynamicbyte`),主键与二级索引照搬。
 

+ 25 - 0
analyzer/layout_assert.go

@@ -2,6 +2,8 @@ package main
 
 import (
 	"TsDB2/DB/core"
+	"TsDB2/DB/core/types"
+	"TsDB2/DB/core/util"
 	"unsafe"
 )
 
@@ -47,4 +49,27 @@ var (
 	_ [IM_FLDS]byte  = [unsafe.Offsetof(core.IndexMeta{}.FieldIDs)]byte{}
 	_ [IM_EP]byte    = [unsafe.Offsetof(core.IndexMeta{}.EntryPage)]byte{}
 	_ [IM_FLAG]byte  = [unsafe.Offsetof(core.IndexMeta{}.Flag)]byte{}
+
+	// 位掩码同样是"读盘约定": analyzer 里手写的位必须与 core 的常量一致。
+	// 读错位的后果和读错偏移一样安静 —— 列 / 索引照样渲染出来, 只是"可空"
+	// 读成了"非空"、"唯一"读成了"普通"。
+	_ [FLAG_DELETED - int(types.FlagDeleted)]struct{}
+	_ [int(types.FlagDeleted) - FLAG_DELETED]struct{}
+	_ [FLAG_NULLABLE - int(types.FlagNullable)]struct{}
+	_ [int(types.FlagNullable) - FLAG_NULLABLE]struct{}
+	_ [FLAG_UNIQUE - int(types.FlagUnique)]struct{}
+	_ [int(types.FlagUnique) - FLAG_UNIQUE]struct{}
+	_ [FLAG_SCALE - int(types.FlagScaleMask)]struct{}
+	_ [int(types.FlagScaleMask) - FLAG_SCALE]struct{}
+
+	_ [IM_FLAG_UNIQUE - int(core.IndexFlagUnique)]struct{}
+	_ [int(core.IndexFlagUnique) - IM_FLAG_UNIQUE]struct{}
 )
+
+// init 兜底: core 没有给"这一位是删除位"单列常量 (那一位的语义由 util.IsHighBitSet
+// 表达), 所以它只能用运行时断言。只跑一次, 不在热路径上。
+func init() {
+	if !util.IsHighBitSet(IM_FLAG_DELETED) {
+		panic("IM_FLAG_DELETED 与 core/util.IsHighBitSet 的判定不一致: 删除位读错会让已删的索引照常渲染")
+	}
+}

+ 47 - 11
analyzer/main.go

@@ -87,7 +87,20 @@ const (
 	IM_FCNT  = 65 // FieldCnt byte
 	IM_FLDS  = 66 // FieldIDs [16]byte
 	IM_EP    = 84 // EntryPage uint32 (跳过 2 字节对齐填充)
-	IM_FLAG  = 96 // Flag byte (0x80 = 已删除/空洞)
+	IM_FLAG  = 96 // Flag byte
+
+	// 索引 Flag 的位 (对应 core 的 IndexFlag*: bit7 软删除 / bit0 唯一索引)。
+	// 取值由 layout_assert.go 拿 core 的常量编译期钉住 —— 这里手写位掩码是为了
+	// 不依赖 core 的常量名, 但绝不能和它漂移。
+	IM_FLAG_DELETED = 0x80
+	IM_FLAG_UNIQUE  = 0x01
+
+	// 列 (FieldInfo) Flag 的位, 对应 core/types 的 Flag* 常量:
+	//   bit7 = 已删除 / bit6 = 可为 NULL / bit5 = 唯一 / bit4..bit0 = decimal scale
+	FLAG_DELETED  = 0x80
+	FLAG_NULLABLE = 0x40
+	FLAG_UNIQUE   = 0x20
+	FLAG_SCALE    = 0x1f
 )
 
 // ===== Data Structures =====
@@ -116,9 +129,18 @@ type MFInfo struct {
 	//
 	// decimal 的 scale 在低 5 位 (如 decimal:2 → 2); 删除标记不再占用 Typ 的最高位。
 
-	Flag    byte `json:"flag"`
-	Sz      int  `json:"sz"`
-	Dropped bool `json:"dropped"`
+	Flag byte `json:"flag"`
+	// Param decimal 的列级 scale (Flag 低 5 位), 非 decimal 列为 0。
+	Param byte `json:"param"`
+	// Nullable / Unique 从 Flag 拆出来的状态位。
+	//
+	// 位掩码只在这一个地方拆一次, 前端 / 检查逻辑都用这两个布尔 —— 每个消费方
+	// 各自去 &0x40 / &0x20, 迟早会有人写错位, 而"列可不可空"读错的后果是悄悄
+	// 把 NULL 当成 0 来解释。
+	Nullable bool `json:"nullable"`
+	Unique   bool `json:"unique"`
+	Sz       int  `json:"sz"`
+	Dropped  bool `json:"dropped"`
 }
 type VInfo struct {
 	Ver     byte     `json:"ver"`
@@ -136,6 +158,15 @@ type IInfo struct {
 	TS   string `json:"ts"`
 	EP   uint32 `json:"ep"`
 	Flds []int  `json:"flds"`
+	// Uniq 唯一索引 (IndexMeta.Flag 的 bit0)。索引的 Flag 字节里只有两位:
+	//
+	//	bit7   bit6              bit0
+	//	已删除  (未用)            唯一索引
+	//
+	// 唯一位不参与一致性校验 (它只约束写入路径, 不影响盘上结构), 但漏掉它会让
+	// 分析器把唯一索引和普通索引用同一种样子画出来 —— 从外部看, 一张表的约束
+	// 就完全不可见了。
+	Uniq bool `json:"uniq"`
 }
 type DP struct {
 	ID uint32  `json:"id"`
@@ -330,11 +361,12 @@ func pTabs() []TInfo {
 					// IndexMeta struct (Name[64] + Type + FieldCnt + FieldIDs[16] + pad(2)
 					//   + EntryPage + Prev + Next + Flag + pad(3) = 100): 各字段偏移见文件头的 IM_*
 					//   常量, 由 layout_assert.go 的编译期断言钉在 DB/core 的真实结构体上。
-					if im[IM_FLAG]&0x80 == 0 {
-						in := strings.TrimRight(string(im[:IM_NAMSZ]), "\x00") // IndexName: [0, IndexNameSize)
-						it := im[IM_TYPE]                                      // IndexType
-						ic := int(im[IM_FCNT])                                 // FieldCnt
-						ep := binary.LittleEndian.Uint32(im[IM_EP : IM_EP+4])   // EntryPage (跳过 2B 对齐填充)
+					if im[IM_FLAG]&IM_FLAG_DELETED == 0 {
+						in := strings.TrimRight(string(im[:IM_NAMSZ]), "\x00")         // IndexName: [0, IndexNameSize)
+						it := im[IM_TYPE]                                             // IndexType
+						ic := int(im[IM_FCNT])                                        // FieldCnt
+						ep := binary.LittleEndian.Uint32(im[IM_EP : IM_EP+4])          // EntryPage (跳过 2B 对齐填充)
+						uq := im[IM_FLAG]&IM_FLAG_UNIQUE != 0                          // 唯一索引
 						// 验证 EntryPage 指向有效索引页 (防止已删除表的孤立条目)
 						if ep != 0 {
 							pb, pe := rp(ep)
@@ -347,7 +379,7 @@ func pTabs() []TInfo {
 							for j := 0; j < ic; j++ {
 								fds = append(fds, int(im[IM_FLDS+j])) // FieldIDs
 							}
-							is = append(is, IInfo{in, it, tNm(it), ep, fds})
+							is = append(is, IInfo{Name: in, Type: it, TS: tNm(it), EP: ep, Flds: fds, Uniq: uq})
 						}
 					}
 					io += META_SZ
@@ -450,7 +482,11 @@ func parseSchemaFields(b []byte) (byte, []MFInfo, []VInfo) {
 		// typ 是纯类型值 (删除标记不再占最高位), 状态位全在 flag 里。
 		masterFields = append(masterFields, MFInfo{
 			Name: name, Type: typ, TypeName: fName(typ),
-			Flag: flag, Sz: fSz(typ), Dropped: flag&0x80 != 0,
+			Flag: flag, Sz: fSz(typ),
+			Param:    flag & FLAG_SCALE,
+			Dropped:  flag&FLAG_DELETED != 0,
+			Nullable: flag&FLAG_NULLABLE != 0,
+			Unique:   flag&FLAG_UNIQUE != 0,
 		})
 	}
 

+ 137 - 0
analyzer/main_test.go

@@ -6,6 +6,7 @@ import (
 	"testing"
 
 	"TsDB2/DB/core"
+	"TsDB2/DB/core/page"
 	"TsDB2/DB/core/types"
 )
 
@@ -263,3 +264,139 @@ func TestParseSchemaAgainstCore(t *testing.T) {
 		t.Errorf("主版本索引 = %d 个, 期望 %d 个", n, len(fields))
 	}
 }
+
+// TestParseFlagsAgainstCore 列的可空/唯一声明与索引唯一位的读取。
+//
+// 这三件事各自只藏在盘上的一个位里 (列 flag 的 bit6 / bit5, 索引 flag 的 bit0)。
+// 读错了不会报错, 只会把"可空"画成"非空"、把唯一约束画成普通索引 —— 一张表的
+// 约束在分析结果里就此消失, 而它恰恰是看 schema 时最想知道的东西。所以这里不重述
+// 位布局, 而是用 core 造一张**真库**, 要求 analyzer 的看法与建表时的声明一致。
+func TestParseFlagsAgainstCore(t *testing.T) {
+	path := "zz_analyzer_flags_guard.db"
+	os.Remove(path)
+	os.Remove(path + ".wal")
+	t.Cleanup(func() {
+		fileMu.Lock()
+		if dbf != nil {
+			dbf.Close()
+			dbf = nil
+		}
+		fileMu.Unlock()
+		retiredMu.Lock()
+		old := retired
+		retired = nil
+		retiredMu.Unlock()
+		for _, f := range old {
+			if f != nil {
+				f.Close()
+			}
+		}
+		if t.Failed() {
+			return // 失败时保留现场
+		}
+		_ = os.Remove(path)
+		_ = os.Remove(path + ".wal")
+	})
+
+	const tableName = "flagtbl"
+	fields := []types.FieldInfo{
+		{FieldType: types.FieldType_8byte, FieldName: []byte("_id")},
+		{FieldType: types.FieldType_8byte, FieldName: []byte("Note"), Flag: types.FlagNullable | types.FlagUnique},
+		{FieldType: types.FieldType_8byte, FieldName: []byte("Plain")},
+		{FieldType: types.FieldType_Decimal, FieldName: []byte("Amount"), Flag: 3},
+	}
+
+	db, err := core.Open(path)
+	if err != nil {
+		t.Fatalf("core.Open: %v", err)
+	}
+	tbl, err := db.CreateTable(tableName, fields)
+	if err != nil {
+		t.Fatalf("core.CreateTable: %v", err)
+	}
+	// Note 上挂唯一索引 (列声明与索引约束都到位), Plain 上挂普通索引作对照,
+	// 主键索引不带唯一位 (行存储索引的 pkID 自成唯一, 标记是二级索引的事)。
+	for _, spec := range []struct {
+		name   string
+		typ    page.IndexType
+		col    byte
+		unique bool
+	}{
+		{"pk_" + tableName, page.IndexTypePrimary, 0, false},
+		{"idx_note", page.IndexTypeBTree, 1, true},
+		{"idx_plain", page.IndexTypeBTree, 2, false},
+	} {
+		if _, err := tbl.CreateIndex(spec.name, spec.typ, []byte{spec.col}, spec.unique); err != nil {
+			t.Fatalf("CreateIndex %s: %v", spec.name, err)
+		}
+	}
+	// 插一行: 索引要有条目才有 EntryPage, 空索引在盘上没有数据页, analyzer 会把
+	// 它当成"没有索引"(见索引解析里对 ep == 0 的过滤)。要验证的是索引上的唯一位,
+	// 就得让索引真的建起来。Amount (decimal) 不给值, 非空列会落零值。
+	if _, err := tbl.InsertRow([]types.FieldValue{
+		{FieldName: []byte("_id"), Value: uint64(1)},
+		{FieldName: []byte("Note"), Value: uint64(7)},
+		{FieldName: []byte("Plain"), Value: uint64(9)},
+	}); err != nil {
+		t.Fatalf("InsertRow: %v", err)
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("core.Commit: %v", err)
+	}
+	if err := db.Close(); err != nil {
+		t.Fatalf("core.Close: %v", err)
+	}
+
+	// 走 analyzer 自己的链路
+	if err := useDB(path); err != nil {
+		t.Fatalf("useDB: %v", err)
+	}
+	invalidateAudit()
+	mu.Lock()
+	rHdr()
+	tabs = pTabs()
+	got := append([]TInfo(nil), tabs...)
+	mu.Unlock()
+
+	if len(got) != 1 {
+		t.Fatalf("解析出 %d 张表, 期望 1 张", len(got))
+	}
+	tb := got[0]
+	if len(tb.MasterFields) != len(fields) {
+		t.Fatalf("masterFields = %d 个, 期望 %d 个", len(tb.MasterFields), len(fields))
+	}
+
+	wantNullable := map[string]bool{"_id": false, "Note": true, "Plain": false, "Amount": false}
+	wantUnique := map[string]bool{"_id": false, "Note": true, "Plain": false, "Amount": false}
+	for _, f := range tb.MasterFields {
+		if f.Nullable != wantNullable[f.Name] {
+			t.Errorf("列 %s 的可空 = %v, 期望 %v (flag=0x%02x)",
+				f.Name, f.Nullable, wantNullable[f.Name], f.Flag)
+		}
+		if f.Unique != wantUnique[f.Name] {
+			t.Errorf("列 %s 的唯一声明 = %v, 期望 %v (flag=0x%02x)",
+				f.Name, f.Unique, wantUnique[f.Name], f.Flag)
+		}
+		if f.Name == "Amount" && f.Param != 3 {
+			t.Errorf("decimal 列的 scale = %d, 期望 3 —— scale 与状态位共用一个字节, 取值串位了",
+				f.Param)
+		}
+	}
+
+	if len(tb.Idxs) != 3 {
+		t.Fatalf("索引 = %d 个, 期望 3 个: %v", len(tb.Idxs), tb.Idxs)
+	}
+	uniq := map[string]bool{}
+	for _, idx := range tb.Idxs {
+		uniq[idx.Name] = idx.Uniq
+	}
+	if !uniq["idx_note"] {
+		t.Errorf("唯一索引 idx_note 没被认出来 (idx flag 的 bit0): %v", tb.Idxs)
+	}
+	if uniq["idx_plain"] {
+		t.Errorf("普通索引 idx_plain 被错认成唯一: %v", tb.Idxs)
+	}
+	if uniq["pk_"+tableName] {
+		t.Errorf("行存储主键索引不该带唯一位: %v", tb.Idxs)
+	}
+}

+ 5 - 2
analyzer/web/index.html

@@ -415,8 +415,11 @@ function showTable(nm){
  if(t.masterFields)t.masterFields.forEach((f,i)=>{
   let st=f.dropped?'<span class="badge dropped">DROPPED</span>':'<span class="badge active">ACTIVE</span>';
   let nmStyle=f.dropped?'color:#e74c3c;text-decoration:line-through':'color:#5b9bd5;font-weight:600';
+  // NULL / UNIQUE 由后端把 flag 字节拆好 (nullable / unique), 前端不再自己碰位掩码
+  let fl=(f.nullable?' <span class="badge btree" title="可为 NULL (行内 NULL 位图据此置位)">NULL</span>':'')+
+   (f.unique?' <span class="badge btree" title="列级唯一声明; 约束由唯一索引执行">UNIQUE</span>':'');
   h+='<tr><td class="mono">'+i+'</td><td style="'+nmStyle+'">'+esc(f.name)+'</td>'+
-  '<td><span class="badge primary">'+f.typeName+(f.param?'('+f.param+')':'')+'</span></td><td>'+f.sz+' bytes</td><td>'+st+'</td></tr>';
+  '<td><span class="badge primary">'+f.typeName+(f.param?'('+f.param+')':'')+'</span>'+fl+'</td><td>'+f.sz+' bytes</td><td>'+st+'</td></tr>';
  });
  h+='</tbody></table></div></div>';
 
@@ -445,7 +448,7 @@ function showTable(nm){
   h+='<div class="idx-card" onclick="showIndex(\''+esc(idx.name)+'\','+idx.ep+','+idx.type+')">'+
    '<div class="idx-ico" style="background:rgba(91,155,213,.15)">'+(idx.type===2?'&#x1F511;':idx.type===1?'&#x23F0;':idx.type===3?'&#x1F524;':idx.type===5?'&#x1F333;':'&#x1F517;')+'</div>'+
    '<div class="idx-info"><div class="idx-name">'+esc(idx.name)+'</div>'+
-   '<div class="idx-meta"><span class="badge '+ic+'">'+idx.ts+'</span><span>Fields: '+esc(flds)+'</span><span>Root: '+idx.ep+'</span></div></div>'+
+   '<div class="idx-meta"><span class="badge '+ic+'">'+idx.ts+'</span>'+(idx.uniq?'<span class="badge btree" title="唯一索引: 同一索引键只允许一行 (任一分量为 NULL 的行不进索引)">UNIQUE</span>':'')+'<span>Fields: '+esc(flds)+'</span><span>Root: '+idx.ep+'</span></div></div>'+
    '<div class="idx-arrow">&#x203A;</div></div>';
  });
  h+='</div></div></div>';

+ 17 - 8
docs/MIGRATION_GUIDE.md

@@ -274,13 +274,15 @@ CREATE TABLE IF NOT EXISTS "__vrtx_meta" (tbl TEXT PRIMARY KEY, payload TEXT NOT
 
 - `pk` / `pkn` / `pkt` — 主键列名、行存储索引名、行存储索引类型(primary / time);
 - `rs` — 行存储索引的分量列(时序表用;普通表为空,表示"就是主键列");
-- `cols[]` — 每列的**定长档位**(VRTX `FieldType` 的原始字节);
-- `idx[]` — 每个二级索引的名字、类型(B+ 树 / Hash / 复合)与分量列顺序。
+- `cols[]` — 每列的**定长档位**(VRTX `FieldType` 的原始字节)、可空位 `nl`、
+  列级唯一声明 `uq`(声明与约束是两件事,见 §9.5);
+- `idx[]` — 每个二级索引的名字、类型(B+ 树 / Hash / 复合)、分量列顺序与唯一位 `u`。
 
 有了它,`VRTX → SQLite → VRTX` 拿回来的库与原库**逐列逐行等价**。
 
 **外部库(没有 `__vrtx_meta`)也能导**:类型按 SQL 声明就近推断,索引一律按目标引擎
-默认(B+ 树)建。不想要这张表就加 `-no-meta`。
+默认(B+ 树)建,可空性与唯一性按 SQL 侧读到的来(SQLite 的 `PRAGMA table_info.notnull`
+与 `index_list`、MySQL 的 `IS_NULLABLE` 与 `STATISTICS.NON_UNIQUE`)。不想要这张表就加 `-no-meta`。
 
 保真表本身不参与迁移:默认跳过,只有显式 `-tables __vrtx_meta` 才会被搬过去。
 
@@ -395,11 +397,18 @@ rm -f /data/app.db                      # VRTX 目标先清掉
    `TEXT` 落变长。超过 256 字节的字符串在 VRTX 里是变长列,**不能再建索引**。
 4. **复合主键不保留**:SQL 的复合主键会被折叠(`columns()` 只认单列主键),必要时
    按"无主键 + `-auto-pk`"处理,并按普通列重建索引。
-5. **`unique` 不随迁移走**:SQL 侧的 `UNIQUE` 属性本工具**不读取也不写出** —— 迁过去的
-   索引一律是非唯一索引(读取端只取索引名与分量,见 `dialect.indexes`;写出端按 `__vrtx_meta`
-   重建时也不带唯一标记)。VRTX 侧另行声明了 `unique` 时,约束本身是生效的:落成唯一索引
-   并在写入时校验(重复报 `ErrDuplicatedKey`),因此**源表有重复值就会在建索引这一步失败**,
-   需先在源侧清理重复值或去掉 `unique`。`NULL` 不参与唯一性,可共存多行 `NULL`。
+5. **`unique` 与 `NULL` 随迁移走,但各自只落成 SQL 能表达的那一半**:
+   - **索引的唯一位**(VRTX `IndexFlagUnique` ⇄ SQL `UNIQUE INDEX`)双向搬运。读取端按
+     `PRAGMA index_list.unique` / `information_schema.STATISTICS.NON_UNIQUE` 认出唯一索引
+     —— 包括 SQLite 里由列级 `UNIQUE` 自动生成、名字不可用的 `sqlite_autoindex_*`(收下后
+     改名,`sqlite_` 是保留前缀);写出端按 `__vrtx_meta` 的 `u` 字段重建 `CREATE UNIQUE INDEX`。
+     因此**源表有重复值就会在建唯一索引这一步失败**,需先在源侧清理重复值或去掉唯一约束;
+     `NULL` 不参与唯一性,可共存多行 `NULL`。
+   - **列的可空性**(VRTX `FlagNullable` ⇄ SQL 的 `NULL` / `NOT NULL`)双向搬运:可空列导出
+     时写 `NULL`,行里的 `NULL` 也原样写成 SQL `NULL`,**不会被折成 `0` / 空串 / `1970-01-01`**
+     (那是不可逆的静默改数据);不可空列仍写 `NOT NULL`。
+   - **列上"声明了 unique 但没有对应唯一索引"这一半只在 `__vrtx_meta` 里保真**:SQL 的列级
+     `UNIQUE` 会真的生成约束,凭空写出等于替用户造约束,所以导出 DDL 时刻意不写这一半。
 6. **时序列的行号不保证一致**:见 §7.3。
 7. **SQLite 写入串行**:写入端 `SetMaxOpenConns(1)`,同一进程内对同一文件的并发迁移
    会串行化。

+ 16 - 8
migrate/mysql.go

@@ -83,7 +83,7 @@ func (mysqlDial) quote(name string) string {
 }
 
 func (d mysqlDial) columnDDL(c Column) string {
-	return d.quote(c.Name) + " " + columnTypeName(c, true) + " NOT NULL"
+	return d.quote(c.Name) + " " + columnTypeName(c, true) + nullClause(c)
 }
 
 // createTable 建表。主键写成表级约束 PRIMARY KEY (col): VRTX 的行存储索引是单列,
@@ -113,8 +113,8 @@ func (d mysqlDial) createIndex(table string, idx IndexSchema) string {
 	for i, c := range idx.Columns {
 		cols[i] = d.quote(c)
 	}
-	return fmt.Sprintf("CREATE INDEX %s ON %s (%s)",
-		d.quote(idx.Name), d.quote(table), strings.Join(cols, ", "))
+	return fmt.Sprintf("%s %s ON %s (%s)",
+		createIndexKind(idx), d.quote(idx.Name), d.quote(table), strings.Join(cols, ", "))
 }
 
 func (d mysqlDial) listTables(db *sql.DB) ([]string, error) {
@@ -140,7 +140,7 @@ func (d mysqlDial) columns(db *sql.DB, table string) ([]Column, string, error) {
 	rows, err := db.Query(`SELECT COLUMN_NAME, DATA_TYPE,
 			COALESCE(CHARACTER_MAXIMUM_LENGTH, 0),
 			COALESCE(NUMERIC_PRECISION, 0), COALESCE(NUMERIC_SCALE, 0),
-			COLUMN_KEY
+			COLUMN_KEY, IS_NULLABLE
 		FROM information_schema.COLUMNS
 		WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
 		ORDER BY ORDINAL_POSITION`, table)
@@ -152,9 +152,9 @@ func (d mysqlDial) columns(db *sql.DB, table string) ([]Column, string, error) {
 	var cols []Column
 	var pkCols []string
 	for rows.Next() {
-		var name, dataType, key string
+		var name, dataType, key, nullable string
 		var charLen, numPrec, numScale int64
-		if err := rows.Scan(&name, &dataType, &charLen, &numPrec, &numScale, &key); err != nil {
+		if err := rows.Scan(&name, &dataType, &charLen, &numPrec, &numScale, &key, &nullable); err != nil {
 			return nil, "", err
 		}
 		decl := dataType
@@ -171,6 +171,7 @@ func (d mysqlDial) columns(db *sql.DB, table string) ([]Column, string, error) {
 			Type:     ft,
 			Param:    param,
 			TimeKind: sqlTimeKind(dataType),
+			Nullable: nullable == "YES", // information_schema 的 IS_NULLABLE
 		})
 		if key == "PRI" {
 			pkCols = append(pkCols, name)
@@ -185,8 +186,13 @@ func (d mysqlDial) columns(db *sql.DB, table string) ([]Column, string, error) {
 	return cols, "", nil
 }
 
+// indexes 读二级索引, 唯一性取 NON_UNIQUE=0。
+//
+// STATISTICS 里同一个索引每列一行, NON_UNIQUE 各行相同, 用 MAX 收成一行;
+// 主键索引 (INDEX_NAME='PRIMARY') 由 PKColumn 表达, 排除在外。
 func (d mysqlDial) indexes(db *sql.DB, table string) ([]IndexSchema, error) {
-	rows, err := db.Query(`SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX)
+	rows, err := db.Query(`SELECT INDEX_NAME, MAX(NON_UNIQUE),
+			GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX)
 		FROM information_schema.STATISTICS
 		WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME <> 'PRIMARY'
 		GROUP BY INDEX_NAME`, table)
@@ -197,7 +203,8 @@ func (d mysqlDial) indexes(db *sql.DB, table string) ([]IndexSchema, error) {
 	var out []IndexSchema
 	for rows.Next() {
 		var name, cols string
-		if err := rows.Scan(&name, &cols); err != nil {
+		var nonUnique int
+		if err := rows.Scan(&name, &nonUnique, &cols); err != nil {
 			return nil, err
 		}
 		out = append(out, IndexSchema{
@@ -206,6 +213,7 @@ func (d mysqlDial) indexes(db *sql.DB, table string) ([]IndexSchema, error) {
 			// 带回来 (见 applyMeta)
 			Type:    page.IndexTypeBTree,
 			Columns: strings.Split(cols, ","),
+			Unique:  nonUnique == 0,
 		})
 	}
 	return out, rows.Err()

+ 365 - 3
migrate/roundtrip_test.go

@@ -7,6 +7,7 @@ import (
 	"TsDB2/DB/core/util"
 	"bytes"
 	"database/sql"
+	"errors"
 	"fmt"
 	"math"
 	"path/filepath"
@@ -171,15 +172,22 @@ func snapshotVRTX(t *testing.T, path, table string) vrtxSnapshot {
 		if util.IsHighBitSet(byte(f.FieldType)) {
 			continue
 		}
-		snap.columns = append(snap.columns, fmt.Sprintf("%s:%s",
-			util.BytesToString(f.FieldName), TypeName(f.FieldType)))
+		// NULL / 唯一声明也进快照: 它们和类型一样是结构的一部分, 往返丢了就是
+		// 静默改结构 (列从"可空"变"非空"、约束从"唯一"变"普通")。
+		snap.columns = append(snap.columns, fmt.Sprintf("%s:%s%s",
+			util.BytesToString(f.FieldName), TypeName(f.FieldType), flagMarks(f.Flag)))
 	}
 	for _, m := range tbl.IndexMeta().GetIndexes() {
 		var cols []string
 		for _, fid := range m.Fields() {
 			cols = append(cols, util.BytesToString(fields[fid].FieldName))
 		}
-		snap.indexes[m.Name()] = fmt.Sprintf("%s(%s)", IndexTypeName(m.IndexType), strings.Join(cols, ","))
+		mark := ""
+		if m.Unique() {
+			mark = " unique"
+		}
+		snap.indexes[m.Name()] = fmt.Sprintf("%s(%s)%s",
+			IndexTypeName(m.IndexType), strings.Join(cols, ","), mark)
 	}
 	if err := tbl.Query().ScanWithPKID(func(pkID uint64, vals []types.FieldValue) bool {
 		m := make(map[string]any, len(vals))
@@ -811,3 +819,357 @@ func TestRunContinueOnError(t *testing.T) {
 		t.Fatal("默认档应当在第一张失败的表上就中止并报错")
 	}
 }
+
+// ============================ NULL / 唯一约束 ============================
+
+// flagMarks 列上的 NULL / 唯一 / decimal scale 声明, 拼成快照里的后缀。
+func flagMarks(flag byte) string {
+	var s string
+	if flag&types.FlagNullable != 0 {
+		s += " NULL"
+	}
+	if flag&types.FlagUnique != 0 {
+		s += " UNIQUE"
+	}
+	if sc := flag & types.FlagScaleMask; sc != 0 {
+		s += fmt.Sprintf(" scale=%d", sc)
+	}
+	return s
+}
+
+// buildVRTXNullUnique 建一张把 NULL 与唯一约束都压满的样本表:
+//
+//	_id  8byte   行号 (主键列, 非空)
+//	Name 32byte  非空 + 唯一索引 (列上也带声明位)
+//	Note 8byte   可空 + 唯一索引
+//	Age  2byte   可空, 无索引
+//
+// Note 特意让两行为 NULL: 唯一索引对 NULL 是免除的 (任一分量为 NULL 的行不进索引),
+// 非空唯一列测不出这条 —— 也正是"NULL 不能被写成 0"最容易出问题的地方。
+func buildVRTXNullUnique(t *testing.T, path string) {
+	t.Helper()
+	db, err := core.Open(path)
+	if err != nil {
+		t.Fatalf("建样本 VRTX 库: %v", err)
+	}
+	defer db.Close()
+
+	fields := []types.FieldInfo{
+		{FieldType: types.FieldType_8byte, FieldName: []byte("_id")},
+		{FieldType: types.FieldType_32byte, FieldName: []byte("Name"), Flag: types.FlagUnique},
+		{FieldType: types.FieldType_8byte, FieldName: []byte("Note"), Flag: types.FlagNullable | types.FlagUnique},
+		{FieldType: types.FieldType_2byte, FieldName: []byte("Age"), Flag: types.FlagNullable},
+	}
+	tbl, err := db.CreateTable("People", fields)
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+	for _, spec := range []struct {
+		name   string
+		typ    page.IndexType
+		col    byte
+		unique bool
+	}{
+		{"pk_People", page.IndexTypePrimary, 0, false},
+		{"idx_People_Name", page.IndexTypeBTree, 1, true},
+		{"idx_People_Note", page.IndexTypeBTree, 2, true},
+	} {
+		if _, err := tbl.CreateIndex(spec.name, spec.typ, []byte{spec.col}, spec.unique); err != nil {
+			t.Fatalf("建索引 %s: %v", spec.name, err)
+		}
+	}
+	writeVRTXRows(t, tbl, fields, []sampleRow{
+		{pk: 1, vals: map[string]any{"_id": uint64(1), "Name": []byte("Alice"), "Note": uint64(100), "Age": int16(30)}},
+		// Note / Age 写 nil: 落盘就是 NULL 位图上的一位
+		{pk: 2, vals: map[string]any{"_id": uint64(2), "Name": []byte("Bob"), "Note": nil, "Age": nil}},
+		{pk: 3, vals: map[string]any{"_id": uint64(3), "Name": []byte("Cara"), "Note": nil, "Age": int16(7)}},
+	})
+}
+
+// checkSQLiteShape 直接查中间库的 PRAGMA, 确认可空性与唯一性真的落在了 DDL 上
+// (而不是只记在 __vrtx_meta 里, SQL 侧其实什么都没表达)。
+func checkSQLiteShape(t *testing.T, path, table string, nullable, unique map[string]bool) {
+	t.Helper()
+	db, err := sql.Open("sqlite", path)
+	if err != nil {
+		t.Fatalf("打开中间库: %v", err)
+	}
+	defer db.Close()
+
+	rows, err := db.Query(`PRAGMA table_info("` + table + `")`)
+	if err != nil {
+		t.Fatalf("table_info: %v", err)
+	}
+	gotNullable := map[string]bool{}
+	for rows.Next() {
+		var cid, notNull, pk int
+		var name, decl string
+		var dflt any
+		if err := rows.Scan(&cid, &name, &decl, &notNull, &dflt, &pk); err != nil {
+			t.Fatalf("扫描 table_info: %v", err)
+		}
+		gotNullable[name] = notNull == 0
+	}
+	rows.Close()
+	for col, want := range nullable {
+		if gotNullable[col] != want {
+			t.Errorf("中间库列 %s 的可空性不对: 实际 %v, 期望 %v", col, gotNullable[col], want)
+		}
+	}
+
+	idxRows, err := db.Query(`PRAGMA index_list("` + table + `")`)
+	if err != nil {
+		t.Fatalf("index_list: %v", err)
+	}
+	gotUnique := map[string]bool{}
+	for idxRows.Next() {
+		var seq, uniq, partial int
+		var name, origin string
+		if err := idxRows.Scan(&seq, &name, &uniq, &origin, &partial); err != nil {
+			t.Fatalf("扫描 index_list: %v", err)
+		}
+		if origin == "c" { // 显式 CREATE INDEX 建的才是我们要比对的
+			gotUnique[name] = uniq != 0
+		}
+	}
+	idxRows.Close()
+	for name, want := range unique {
+		if gotUnique[name] != want {
+			t.Errorf("中间库索引 %s 的唯一性不对: 实际 %v, 期望 %v", name, gotUnique[name], want)
+		}
+	}
+}
+
+// TestVRTXSQLiteNullUniqueRoundTrip 可空列 + 唯一索引的 VRTX → SQLite → VRTX。
+//
+// 这两个属性在两端各只有一半的表达: NULL 在 VRTX 是行内位图的位、在 SQL 是列可空
+// 声明加上行里的 NULL; 唯一在 VRTX 是索引上的标志位、在 SQL 是 UNIQUE INDEX。
+// 少带任何一半, 往返都会静默变形 —— NULL 被写成一个 0, 或者约束悄悄退化成普通索引。
+func TestVRTXSQLiteNullUniqueRoundTrip(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "src.db")
+	midPath := filepath.Join(dir, "mid.sqlite")
+	backPath := filepath.Join(dir, "back.db")
+
+	buildVRTXNullUnique(t, srcPath)
+	want := snapshotVRTX(t, srcPath, "People")
+
+	v1, err := OpenVRTX(srcPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	s1, err := OpenSQLite(midPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := Run(v1, s1, quiet()); err != nil {
+		t.Fatalf("VRTX→SQLite: %v", err)
+	}
+	_ = v1.Close()
+	_ = s1.Close()
+
+	// 中间库的结构: 可空列写 NULL (不是硬写 NOT NULL), 索引写 UNIQUE INDEX
+	checkSQLiteShape(t, midPath, "People",
+		map[string]bool{"_id": false, "Name": false, "Note": true, "Age": true},
+		map[string]bool{"idx_People_Name": true, "idx_People_Note": true})
+
+	s2, err := OpenSQLite(midPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	v2, err := OpenVRTX(backPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := Run(s2, v2, quiet()); err != nil {
+		t.Fatalf("SQLite→VRTX: %v", err)
+	}
+	_ = s2.Close()
+	_ = v2.Close()
+
+	got := snapshotVRTX(t, backPath, "People")
+	if strings.Join(got.columns, "|") != strings.Join(want.columns, "|") {
+		t.Errorf("列结构 (含 NULL / 唯一声明) 不一致\n  源: %v\n  回: %v", want.columns, got.columns)
+	}
+	if len(got.indexes) != len(want.indexes) {
+		t.Errorf("索引数量不一致\n  源: %v\n  回: %v", want.indexes, got.indexes)
+	}
+	for name, def := range want.indexes {
+		if got.indexes[name] != def {
+			t.Errorf("索引 %s 不一致\n  源: %q\n  回: %q", name, def, got.indexes[name])
+		}
+	}
+	if len(got.rows) != len(want.rows) {
+		t.Fatalf("行数不一致: 源 %d, 回 %d", len(want.rows), len(got.rows))
+	}
+	// 逐列比对, 并且把"源里有值/回里没值"也算不一致 —— NULL 往返丢了不会体现在
+	// norm() 上, 只会体现为"这一列在 map 里消失了"。
+	for pk, wantRow := range want.rows {
+		gotRow, ok := got.rows[pk]
+		if !ok {
+			t.Errorf("pk=%d 的行丢了", pk)
+			continue
+		}
+		for _, col := range []string{"Name", "Note", "Age"} {
+			wantVal, wantHas := wantRow[col]
+			gotVal, gotHas := gotRow[col]
+			if wantHas != gotHas {
+				t.Errorf("pk=%d 列 %s 的有无不一致: 源 %v (有值=%v), 回 %v (有值=%v) —— NULL 被写成零值了",
+					pk, col, wantVal, wantHas, gotVal, gotHas)
+				continue
+			}
+			if wantHas && norm(gotVal) != norm(wantVal) {
+				t.Errorf("pk=%d 列 %s 不一致: 源 %s, 回 %s", pk, col, norm(wantVal), norm(gotVal))
+			}
+		}
+	}
+
+	// 约束回到 VRTX 后必须真的生效, 不是画在 schema 上的装饰
+	back, err := core.Open(backPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer back.Close()
+	btbl, ok := back.FindTable("People")
+	if !ok {
+		t.Fatal("回来的库里没有 People 表")
+	}
+	if _, err := btbl.InsertRow([]types.FieldValue{
+		{FieldName: []byte("Name"), Value: []byte("Alice")},
+	}); !errors.Is(err, core.ErrDuplicatedKey) {
+		t.Errorf("重复 Name 应当报 ErrDuplicatedKey, 实际 %v", err)
+	}
+	if _, err := btbl.InsertRow([]types.FieldValue{
+		{FieldName: []byte("Name"), Value: []byte("Dan")},
+		{FieldName: []byte("Note"), Value: uint64(100)},
+	}); !errors.Is(err, core.ErrDuplicatedKey) {
+		t.Errorf("重复 Note 应当报 ErrDuplicatedKey, 实际 %v", err)
+	}
+	// NULL 不算重复: 再加两行 Note=NULL 都该进得去
+	for _, nm := range []string{"Eve", "Fay"} {
+		if _, err := btbl.InsertRow([]types.FieldValue{
+			{FieldName: []byte("Name"), Value: []byte(nm)},
+			{FieldName: []byte("Note"), Value: nil},
+		}); err != nil {
+			t.Errorf("Note=NULL 的 %s 应当能插入 (唯一索引对 NULL 免除), 实际 %v", nm, err)
+		}
+	}
+}
+
+// TestSQLiteUniqueConstraintToVRTX 外部库用【列级 UNIQUE 约束】时, 约束也要跟着走。
+//
+// 这种写法在 SQLite 里没有显式索引, 只生成一条 sqlite_autoindex_* (origin='u')。
+// 原实现只收 origin='c' 并跳过它, 注释理由是"VRTX 没有唯一约束的概念" —— 现在有了,
+// 跳过就等于把用户的约束悄悄删掉。名字也不能照搬 (sqlite_ 前缀是保留的), 要改。
+func TestSQLiteUniqueConstraintToVRTX(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "ext.sqlite")
+	dstPath := filepath.Join(dir, "ext.db")
+
+	db, err := sql.Open("sqlite", srcPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	// code 用 INTEGER: TEXT 落到 VRTX 是动态列 (堆存), 动态列不能当索引键
+	// (引擎会拒, migrate 记进 SkippedIndexes), 那样测的就不是唯一约束的迁移了。
+	for _, q := range []string{
+		`CREATE TABLE t (id INTEGER PRIMARY KEY, code INTEGER UNIQUE, note TEXT)`,
+		`INSERT INTO t (id, code, note) VALUES (1, 100, 'x')`,
+		`INSERT INTO t (id, code, note) VALUES (2, 200, NULL)`,
+	} {
+		if _, err := db.Exec(q); err != nil {
+			t.Fatalf("%s: %v", q, err)
+		}
+	}
+	_ = db.Close()
+
+	src, err := OpenSQLite(srcPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	sc, err := src.Schema("t")
+	if err != nil {
+		t.Fatalf("读外部库结构: %v", err)
+	}
+	var uniq *IndexSchema
+	for i := range sc.Indexes {
+		if sc.Indexes[i].Unique {
+			uniq = &sc.Indexes[i]
+		}
+	}
+	if uniq == nil {
+		t.Fatalf("code 的唯一约束没读出来, 索引: %v", sc.Indexes)
+	}
+	if len(uniq.Columns) != 1 || uniq.Columns[0] != "code" {
+		t.Fatalf("唯一索引的分量不对: %v", uniq.Columns)
+	}
+	if strings.HasPrefix(uniq.Name, "sqlite_") {
+		t.Errorf("唯一索引名不该照搬 SQLite 的保留名: %s", uniq.Name)
+	}
+
+	dst, err := OpenVRTX(dstPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := Run(src, dst, quiet()); err != nil {
+		t.Fatalf("SQLite→VRTX: %v", err)
+	}
+	_ = src.Close()
+	_ = dst.Close()
+
+	back, err := core.Open(dstPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer back.Close()
+	tbl, ok := back.FindTable("t")
+	if !ok {
+		t.Fatal("VRTX 里没有 t 表")
+	}
+
+	// 1) 唯一性落到索引上, 且列上补出了"唯一列"的声明位
+	var uniqueIdx core.IndexMeta
+	hasUnique := false
+	for _, m := range tbl.IndexMeta().GetIndexes() {
+		if m.Unique() {
+			uniqueIdx, hasUnique = m, true
+		}
+	}
+	if !hasUnique {
+		t.Fatalf("唯一约束没迁过来: 库里一条唯一索引都没有")
+	}
+	codeFid := -1
+	for i, f := range tbl.GetFields() {
+		if util.BytesToString(f.FieldName) == "code" {
+			codeFid = i
+		}
+	}
+	if codeFid < 0 {
+		t.Fatal("找不到 code 列")
+	}
+	if !tbl.GetFields()[codeFid].IsUnique() {
+		t.Errorf("code 列应当带上唯一声明位")
+	}
+	if got := uniqueIdx.Fields(); len(got) != 1 || got[0] != byte(codeFid) {
+		t.Errorf("唯一索引的分量应当是 code(%d), 实际 %v", codeFid, got)
+	}
+
+	// 2) 约束真的会拦: 重复 code 被拒
+	if _, err := tbl.InsertRow([]types.FieldValue{
+		{FieldName: []byte("id"), Value: uint64(3)},
+		{FieldName: []byte("code"), Value: uint64(100)},
+	}); !errors.Is(err, core.ErrDuplicatedKey) {
+		t.Errorf("重复 code 应当报 ErrDuplicatedKey, 实际 %v", err)
+	}
+
+	// 3) NULL 也保住了 (note 第 2 行是 NULL)
+	row, err := tbl.FindByPK(2)
+	if err != nil {
+		t.Fatalf("读回 pk=2: %v", err)
+	}
+	for _, fv := range row {
+		if util.BytesToString(fv.FieldName) == "note" {
+			t.Errorf("note 本该是 NULL (不出现), 实际 %v", fv.Value)
+		}
+	}
+}

+ 47 - 2
migrate/sql.go

@@ -17,6 +17,27 @@ import (
 const MetaTable = "__vrtx_meta"
 
 // dialect SQL 方言。MySQL 与 SQLite 只有这些差异, 读写流程完全共用。
+// nullClause 建表时的可空声明。
+//
+// 只有"不可空"才写 NOT NULL —— 可空列不能靠"什么都不写"来表达吗? 能, 但显式
+// 写一个 NULL 更好: 生成出来的 DDL 一眼能看出这列是【有意可空】, 而不是从
+// VRTX 导过来时被漏掉了可空声明 (两者的 DDL 长得一样, 只有写了 NULL 才区分得开)。
+func nullClause(c Column) string {
+	if c.Nullable {
+		return " NULL"
+	}
+	return " NOT NULL"
+}
+
+// createIndexKind 索引 DDL 的动词前缀。唯一索引在 SQL 侧就是 UNIQUE INDEX,
+// 没有别的表达方式 —— 少了它, VRTX 的唯一约束导出到 SQL 就只剩普通索引。
+func createIndexKind(idx IndexSchema) string {
+	if idx.Unique {
+		return "CREATE UNIQUE INDEX"
+	}
+	return "CREATE INDEX"
+}
+
 type dialect interface {
 	kind() Kind
 	quote(name string) string
@@ -441,12 +462,21 @@ type metaCol struct {
 	// 盘上它就是 8byte (Unix 纳秒), 靠这个标记导回 SQL 时才还原成 DATETIME ——
 	// 光看 FieldType 分不出"时间戳"和"恰好取值很大的整数"。
 	Time string `json:"tm,omitempty"`
+	// Null 该列可空。SQL 侧的可空性虽然能从 PRAGMA / information_schema 读出来,
+	// 但那是【建表时写下的声明】; 我们导出时按 VRTX 的 NULL 位图写 NULL / NOT NULL,
+	// 带一份在保真表里, 回来就不必依赖各家 SQL 方言的可空推断。
+	Null bool `json:"nl,omitempty"`
+	// Uniq 该列声明了唯一约束。列位与索引唯一位是两件事 (声明 vs 约束),
+	// 各存各的: 只存索引的话, 导回 VRTX 后列上的声明就没了。
+	Uniq bool `json:"uq,omitempty"`
 }
 
 type metaIdx struct {
 	Name    string   `json:"n"`
 	Type    byte     `json:"t"`
 	Columns []string `json:"c"`
+	// Uniq 唯一索引 (SQL 侧的 UNIQUE INDEX / VRTX 的 IndexFlagUnique)。
+	Uniq bool `json:"u,omitempty"`
 }
 
 const metaVersion = 1
@@ -470,10 +500,15 @@ func (s *SQL) writeMeta(sc *TableSchema) error {
 		RowStore:    sc.RowStoreColumns,
 	}
 	for _, c := range sc.Columns {
-		dto.Columns = append(dto.Columns, metaCol{Name: c.Name, Type: byte(c.Type), Param: c.Param, Time: c.TimeKind})
+		dto.Columns = append(dto.Columns, metaCol{
+			Name: c.Name, Type: byte(c.Type), Param: c.Param, Time: c.TimeKind,
+			Null: c.Nullable, Uniq: c.Unique,
+		})
 	}
 	for _, idx := range sc.Indexes {
-		dto.Indexes = append(dto.Indexes, metaIdx{Name: idx.Name, Type: byte(idx.Type), Columns: idx.Columns})
+		dto.Indexes = append(dto.Indexes, metaIdx{
+			Name: idx.Name, Type: byte(idx.Type), Columns: idx.Columns, Uniq: idx.Unique,
+		})
 	}
 	payload, err := json.Marshal(dto)
 	if err != nil {
@@ -524,6 +559,8 @@ func applyMeta(s *TableSchema, m *metaDTO) {
 	typeOf := make(map[string]types.FieldType, len(m.Columns))
 	timeOf := make(map[string]string, len(m.Columns))
 	paramOf := make(map[string]byte, len(m.Columns))
+	nullOf := make(map[string]bool, len(m.Columns))
+	uniqOf := make(map[string]bool, len(m.Columns))
 	for _, c := range m.Columns {
 		typeOf[c.Name] = types.FieldType(c.Type)
 		if c.Param != 0 {
@@ -532,10 +569,16 @@ func applyMeta(s *TableSchema, m *metaDTO) {
 		if c.Time != "" {
 			timeOf[c.Name] = c.Time
 		}
+		nullOf[c.Name] = c.Null
+		uniqOf[c.Name] = c.Uniq
 	}
 	for i := range s.Columns {
 		if t, ok := typeOf[s.Columns[i].Name]; ok {
 			s.Columns[i].Type = t
+			// NULL / 唯一声明无条件取自保真表: 旧格式 (没这两个字段) 解出来是 false,
+			// 而旧格式导出的表当年就是"全列 NOT NULL + 无唯一约束", 语义正好对上。
+			s.Columns[i].Nullable = nullOf[s.Columns[i].Name]
+			s.Columns[i].Unique = uniqOf[s.Columns[i].Name]
 		}
 		// Param 无条件覆盖: 保真表里没记 (旧格式) 时是 0, 而推断出来的 SQL DECIMAL
 		// 也可能带 scale —— 以保真表为准才叫"还原当初的结构"。
@@ -568,6 +611,7 @@ func applyMeta(s *TableSchema, m *metaDTO) {
 	for i := range s.Indexes {
 		if mi, ok := byName[s.Indexes[i].Name]; ok {
 			s.Indexes[i].Type = page.IndexType(mi.Type)
+			s.Indexes[i].Unique = mi.Uniq
 			if len(mi.Columns) > 0 {
 				s.Indexes[i].Columns = mi.Columns
 			}
@@ -585,6 +629,7 @@ func applyMeta(s *TableSchema, m *metaDTO) {
 			Name:    mi.Name,
 			Type:    page.IndexType(mi.Type),
 			Columns: mi.Columns,
+			Unique:  mi.Uniq,
 		})
 	}
 }

+ 30 - 9
migrate/sqlite.go

@@ -35,7 +35,7 @@ func (sqliteDial) quote(name string) string {
 }
 
 func (d sqliteDial) columnDDL(c Column) string {
-	return d.quote(c.Name) + " " + columnTypeName(c, false) + " NOT NULL"
+	return d.quote(c.Name) + " " + columnTypeName(c, false) + nullClause(c)
 }
 
 func (d sqliteDial) createTable(s *TableSchema) string {
@@ -64,8 +64,8 @@ func (d sqliteDial) createIndex(table string, idx IndexSchema) string {
 	}
 	// SQLite 的索引名是库级唯一的 (不像 MySQL 按表命名空间), 撞名时退让:
 	// 迁移过来的 VRTX 索引名通常自带表名, 这里只是兜底。
-	return fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)",
-		d.quote(idx.Name), d.quote(table), strings.Join(cols, ", "))
+	return fmt.Sprintf("%s IF NOT EXISTS %s ON %s (%s)",
+		createIndexKind(idx), d.quote(idx.Name), d.quote(table), strings.Join(cols, ", "))
 }
 
 func (d sqliteDial) listTables(db *sql.DB) ([]string, error) {
@@ -117,6 +117,7 @@ func (d sqliteDial) columns(db *sql.DB, table string) ([]Column, string, error)
 			Type:     ft,
 			Param:    param,
 			TimeKind: sqlTimeKind(decl),
+			Nullable: notNull == 0, // PRAGMA 的 notnull: 0 = 该列可为空
 		})
 		if pk > 0 {
 			pks = append(pks, pkCol{seq: pk, name: name})
@@ -126,15 +127,28 @@ func (d sqliteDial) columns(db *sql.DB, table string) ([]Column, string, error)
 		return nil, "", err
 	}
 	if len(pks) == 1 {
+		// SQLite 的 `id INTEGER PRIMARY KEY` 是 rowid 别名, PRAGMA 里 notnull = 0
+		// (非空由"自动生成 rowid"保证, 不由 NOT NULL 约束表达)。而 VRTX 的行存储列
+		// 承载 pkID, 必须有值 —— 不在这里钉住, 外部库导进来就会得到一张"主键列可为空"
+		// 的 VRTX 表。注意这是 SQLite 特有的: 我们导出的表会显式写 NOT NULL, 读回
+		// notnull = 1, 所以这一步只对"外部建的库"生效, 不影响往返。
+		for i := range cols {
+			if cols[i].Name == pks[0].name {
+				cols[i].Nullable = false
+			}
+		}
 		return cols, pks[0].name, nil
 	}
 	// 复合主键 / 无主键: 都当作"没有单列主键", 由调用方决定自增还是报错
 	return cols, "", nil
 }
 
-// indexes 只取显式 CREATE INDEX 建的索引 (origin='c')。
-// origin='u' 是 UNIQUE 约束自动生成的 sqlite_autoindex_*, 'pk' 是主键, 都不是
-// 能按名字重建的东西, 而且 VRTX 也没有唯一约束的概念。
+// indexes 读二级索引, 唯一性照搬 (PRAGMA index_list 的 unique 列)。
+//
+// origin='c' 是显式 CREATE INDEX (普通或唯一); origin='u' 是 UNIQUE 列/表约束
+// 自动生成的 sqlite_autoindex_* —— 名字不是用户起的, 但它就是一条唯一索引, 忽略
+// 它等于把约束丢掉, 所以照收并改个能重建的名字 (sqlite_ 前缀是保留的, 不能拿来
+// 建索引)。origin='pk' 是主键索引, 由 PKColumn 表达, 跳过。
 func (d sqliteDial) indexes(db *sql.DB, table string) ([]IndexSchema, error) {
 	rows, err := db.Query("PRAGMA index_list(" + d.quote(table) + ")")
 	if err != nil {
@@ -143,6 +157,7 @@ func (d sqliteDial) indexes(db *sql.DB, table string) ([]IndexSchema, error) {
 	type entry struct {
 		name   string
 		origin string
+		unique bool
 	}
 	var list []entry
 	for rows.Next() {
@@ -154,7 +169,7 @@ func (d sqliteDial) indexes(db *sql.DB, table string) ([]IndexSchema, error) {
 			rows.Close()
 			return nil, err
 		}
-		list = append(list, entry{name: name, origin: origin})
+		list = append(list, entry{name: name, origin: origin, unique: unique != 0})
 	}
 	if err := rows.Err(); err != nil {
 		rows.Close()
@@ -163,8 +178,9 @@ func (d sqliteDial) indexes(db *sql.DB, table string) ([]IndexSchema, error) {
 	rows.Close()
 
 	var out []IndexSchema
+	uqSeq := 0
 	for _, e := range list {
-		if e.origin != "c" {
+		if e.origin != "c" && e.origin != "u" {
 			continue
 		}
 		cols, err := d.indexColumns(db, e.name)
@@ -174,7 +190,12 @@ func (d sqliteDial) indexes(db *sql.DB, table string) ([]IndexSchema, error) {
 		if len(cols) == 0 {
 			continue
 		}
-		out = append(out, IndexSchema{Name: e.name, Type: page.IndexTypeBTree, Columns: cols})
+		name := e.name
+		if e.origin == "u" {
+			uqSeq++
+			name = fmt.Sprintf("uq_%s_%d", table, uqSeq)
+		}
+		out = append(out, IndexSchema{Name: name, Type: page.IndexTypeBTree, Columns: cols, Unique: e.unique})
 	}
 	return out, nil
 }

+ 45 - 3
migrate/types.go

@@ -53,6 +53,37 @@ type Column struct {
 	// 光看 FieldType 分不出它是"时间戳"还是"恰好取值很大的整数"。带着这个标记,
 	// 导回 SQL 时才能还原成 DATETIME 而不是 BIGINT (标记写进保真表 __vrtx_meta)。
 	TimeKind string
+	// Nullable 该列允许 NULL (VRTX 的 FlagNullable ⇄ SQL 的可空声明)。
+	//
+	// 两边都必须显式带上: 导出时可空列不能写 NOT NULL (否则 NULL 值根本插不进去,
+	// 只能被改成 0/空串), 导入时 SQL 的可空性也要落到 VRTX 的 schema 上 ——
+	// __vrtx_meta 里同样存一份, 因为 SQLite 的 PRAGMA 看得见 notnull 而
+	// SQL 的 TEXT 保真表看不出"整数列能不能为空"。
+	Nullable bool
+	// Unique 该列声明了唯一约束 (VRTX 的 FlagUnique ⇄ SQL 的列级 UNIQUE)。
+	//
+	// 只是【声明】, 真正拦重复的是唯一索引 (见 IndexSchema.Unique) —— 与 VRTX 的
+	// 分工一致: 列位告诉读者/工具"这列的意图", 约束由索引执行。往返时两个标记
+	// 都要带, 否则导入 VRTX 后列上的声明就丢了 (索引还在, 但 ORM 反推时会看不出
+	// 这列本该唯一)。
+	//
+	// 导出到 SQL 时【不】把它写成列级 UNIQUE: SQL 的 UNIQUE 会真的生成一条唯一
+	// 索引, 也就是一个会拦写入的约束, 而这里的声明在 VRTX 侧什么都不拦 —— 写了
+	// 等于凭空造约束, 再导回来还会多出一条索引。它只进 __vrtx_meta 保真表
+	// (见 metaCol.Uniq), 纯导出时确实不落 DDL, 这是有意为之。
+	Unique bool
+}
+
+// Flags 列上的可空/唯一声明, 拼成给人看的一小段后缀 (Describe / 日志用)。
+func (c Column) Flags() string {
+	var s string
+	if c.Nullable {
+		s += " NULL"
+	}
+	if c.Unique {
+		s += " UNIQUE"
+	}
+	return s
 }
 
 // IsTime 这列是否承载 SQL 的时间语义。
@@ -65,7 +96,7 @@ func (c Column) Size() int { return int(types.GetSize(c.Type)) }
 func (c Column) IsDynamic() bool { return types.IsDynamic(c.Type) }
 
 func (c Column) String() string {
-	return fmt.Sprintf("%s %s", c.Name, columnTypeLabel(c))
+	return fmt.Sprintf("%s %s%s", c.Name, columnTypeLabel(c), c.Flags())
 }
 
 // columnTypeLabel 列类型的可读名: Decimal 带上小数位 (decimal(2)), 其余用 TypeName。
@@ -136,6 +167,13 @@ type IndexSchema struct {
 	Name    string
 	Type    page.IndexType
 	Columns []string
+	// Unique 唯一索引: 同一索引键只允许一行 (NULL 免除 —— 任一分量为 NULL 的
+	// 行不进该索引, 与 core.IndexFlagUnique 的语义完全一致)。
+	//
+	// SQL 侧只有"唯一索引"这一个结构对应物, 不给它, 把 VRTX 的唯一约束导出到
+	// SQL 就只剩一条普通索引: 重复值随便进, 再导回 VRTX 时约束已经悄悄没了 ——
+	// 结构变了却没人报错, 所以这个标记必须往返 (__vrtx_meta 里也存一份)。
+	Unique bool
 }
 
 // IsRowStore 是否承载行数据本身 (VRTX 的主键索引 / 时序索引), 即 SQL 侧的主键。
@@ -157,7 +195,11 @@ func (i IndexSchema) SameColumns(cols []string) bool {
 }
 
 func (i IndexSchema) String() string {
-	return fmt.Sprintf("%s(%s) [%s]", i.Name, strings.Join(i.Columns, ","), IndexTypeName(i.Type))
+	kind := IndexTypeName(i.Type)
+	if i.Unique {
+		kind += " unique"
+	}
+	return fmt.Sprintf("%s(%s) [%s]", i.Name, strings.Join(i.Columns, ","), kind)
 }
 
 // IndexTypeName 索引类型的可读名字 (与 page.IndexType 对齐)。
@@ -283,7 +325,7 @@ func (s *TableSchema) Describe() string {
 		if c.Name == s.PKColumn {
 			mark = "PK"
 		}
-		fmt.Fprintf(&b, "    %s %-28s %s\n", mark, c.Name, columnTypeLabel(c))
+		fmt.Fprintf(&b, "    %s %-28s %s%s\n", mark, c.Name, columnTypeLabel(c), c.Flags())
 	}
 	for _, idx := range s.Indexes {
 		fmt.Fprintf(&b, "    IDX %-27s %s\n", idx.String(), "")

+ 16 - 1
migrate/value.go

@@ -70,8 +70,17 @@ func zeroForVRTX(ft types.FieldType) any {
 //
 // param 是列级参数 (Decimal 的 scale): 定点列必须按它对齐小数位, 否则同一列里
 // 混进另一种 scale 的行, B+ 树的字节序比较会静默出错。
-func coerceForVRTX(ft types.FieldType, param byte, v any, col string, strict bool) (any, error) {
+//
+// nullable 决定 nil 怎么落地: 可空列写 NULL (原样把 nil 交给 core, 由它置 NULL
+// 位图的位), 不可空列才退化成零值。
+func coerceForVRTX(ft types.FieldType, param byte, v any, col string, strict bool, nullable bool) (any, error) {
 	if v == nil {
+		// SQL 侧的 NULL 读出来就是 nil (见 SQL.Scan 的 *any)。可空列必须把它写成
+		// NULL —— 落成零值等于"把没有值"变成"值是 0 / 空串", 而且不可逆: 导回去
+		// 分不出这行的 0 原本是 NULL 还是真 0。
+		if nullable {
+			return nil, nil
+		}
 		return zeroForVRTX(ft), nil
 	}
 	switch ft {
@@ -326,6 +335,12 @@ func parseInt128(s string) (*big.Int, error) {
 //     后者保证二进制列不被改坏。定长列导出时去掉尾部补零。
 func coerceForSQL(c Column, v any) (any, error) {
 	if v == nil {
+		// 可空列: 原样写 NULL。以前所有列都不可空, 这里一路把 nil 落成零值 ——
+		// 在可空列上那是【静默改数据】: VRTX 的 NULL 到了 SQL 变成 0 / 空串 /
+		// 1970-01-01, 而且不可逆, 再导回来分不出它原本是 NULL。
+		if c.Nullable {
+			return nil, nil
+		}
 		if c.IsDynamic() {
 			return []byte{}, nil
 		}

+ 44 - 4
migrate/vrtx.go

@@ -87,6 +87,10 @@ func (v *VRTX) Schema(table string) (*TableSchema, error) {
 			Name:  util.BytesToString(f.FieldName),
 			Type:  f.FieldType,
 			Param: f.Flag & types.FlagScaleMask, // Decimal 的列级 scale, 必须跟着列走
+			// NULL / 唯一声明: 前者决定导出时写不写 NOT NULL, 后者是列级声明
+			// (约束本身由索引执行, 见下面 GetIndexes 的 m.Unique())。
+			Nullable: f.IsNullable(),
+			Unique:   f.IsUnique(),
 		})
 	}
 
@@ -135,7 +139,12 @@ func (v *VRTX) Schema(table string) (*TableSchema, error) {
 			v.opts.logf("[vrtx] 表 %s 的索引 %s 有分量列已被删除, 跳过", table, m.Name())
 			continue
 		}
-		s.Indexes = append(s.Indexes, IndexSchema{Name: m.Name(), Type: m.IndexType, Columns: cols})
+		s.Indexes = append(s.Indexes, IndexSchema{
+			Name:    m.Name(),
+			Type:    m.IndexType,
+			Columns: cols,
+			Unique:  m.Unique(), // IndexMeta.Flag 的唯一位: 约束的执行者
+		})
 	}
 
 	// 时序索引: 行存储的键是"时间列", 与行号无关 —— 既不能当 SQL 主键 (主键得是整数),
@@ -253,12 +262,27 @@ func (v *VRTX) CreateTable(s *TableSchema, recreate bool) error {
 		return fmt.Errorf("表名 %s 有 %d 字节, 超过 VRTX 上限 %d", s.Name, len(s.Name), core.TableNameSize)
 	}
 
+	// 单列唯一索引 ⇒ 这一列同时是"唯一列", 把列上的声明位补上。
+	//
+	// 声明与约束是两件事 (列位给读者看, 索引负责拦), 但只有索引这一半能从 SQL 读
+	// 出来 —— SQLite 的列级 UNIQUE 在 PRAGMA table_info 里毫无痕迹, 只生成一条
+	// autoindex。补位不会凭空多出约束 (约束一直是索引给的), 少了它却会让从外部库
+	// 迁进来的表"看不出这列本该唯一"。复合唯一索引不补: (A,B) 唯一不等于 B 唯一。
+	for _, idx := range s.Indexes {
+		if !idx.Unique || len(idx.Columns) != 1 {
+			continue
+		}
+		if ci := columnIndex(s, idx.Columns[0]); ci >= 0 {
+			s.Columns[ci].Unique = true
+		}
+	}
+
 	fields := make([]types.FieldInfo, 0, len(s.Columns))
 	for _, c := range s.Columns {
 		fields = append(fields, types.FieldInfo{
 			FieldType: c.Type,
 			FieldName: []byte(c.Name),
-			Flag:      c.Param & types.FlagScaleMask, // Decimal 的列级 scale
+			Flag:      columnFlag(c), // decimal scale + NULL / 唯一声明
 		})
 	}
 	tbl, err := v.db.CreateTable(s.Name, fields)
@@ -332,7 +356,7 @@ func (v *VRTX) CreateTable(s *TableSchema, recreate bool) error {
 		if idxType == 0 || idxType == page.IndexTypePrimary || idxType == page.IndexTypeTime {
 			idxType = page.IndexTypeBTree
 		}
-		if _, err := tbl.CreateIndex(idx.Name, idxType, ids); err != nil {
+		if _, err := tbl.CreateIndex(idx.Name, idxType, ids, idx.Unique); err != nil {
 			return fmt.Errorf("建索引 %s: %w", idx.Name, err)
 		}
 	}
@@ -376,7 +400,7 @@ func (w *vrtxWriter) Write(rows []Row) error {
 			if i == w.pkIdx {
 				raw = pkID // 主键列是行号的镜像
 			}
-			cv, err := coerceForVRTX(c.Type, c.Param, raw, c.Name, w.opts.StrictValue)
+			cv, err := coerceForVRTX(c.Type, c.Param, raw, c.Name, w.opts.StrictValue, c.Nullable)
 			if err != nil {
 				return err
 			}
@@ -405,6 +429,22 @@ func checkIndexName(name string) error {
 	return nil
 }
 
+// columnFlag 列的 VRTX 标志位: 低 5 位 scale + NULL / 唯一声明。
+//
+// 三者共用一个字节 (布局见 types 的 Flag* 常量), 少写任何一位都是静默改结构:
+// 丢了 FlagNullable, 往这列写 NULL 会被当成"给个零值"塞 0; 丢了 FlagUnique,
+// 反向推 schema 的工具 (以及 ORM 的 tag) 就看不出这列本该唯一。
+func columnFlag(c Column) byte {
+	f := c.Param & types.FlagScaleMask
+	if c.Nullable {
+		f |= types.FlagNullable
+	}
+	if c.Unique {
+		f |= types.FlagUnique
+	}
+	return f
+}
+
 func columnIndex(s *TableSchema, name string) int {
 	if i, ok := s.ColumnIdx(name); ok {
 		return i