yl32940 1 сар өмнө
parent
commit
b146b7354e
63 өөрчлөгдсөн 5111 нэмэгдсэн , 600 устгасан
  1. BIN
      .DS_Store
  2. 13 0
      .gitignore
  3. 281 0
      DB/concurrent_test.go
  4. 16 3
      DB/convert.go
  5. 5 5
      DB/core/EqCondition.go
  6. 101 6
      DB/core/File.go
  7. 5 5
      DB/core/GeCondition.go
  8. 5 5
      DB/core/GtCondition.go
  9. 5 5
      DB/core/InCondition.go
  10. 12 8
      DB/core/IndexMetaPage.go
  11. 5 5
      DB/core/LeCondition.go
  12. 2 3
      DB/core/LikeCondition.go
  13. 5 5
      DB/core/LtCondition.go
  14. 307 93
      DB/core/Table.go
  15. 32 5
      DB/core/TablePage.go
  16. 1 1
      DB/core/condition.go
  17. 34 2
      DB/core/const.go
  18. 91 33
      DB/core/db.go
  19. 269 0
      DB/core/limits_test.go
  20. 86 32
      DB/core/pool/buffer_pool.go
  21. 101 27
      DB/core/pool/buffer_pool_test.go
  22. 17 6
      DB/core/query.go
  23. 307 0
      DB/core/stringwidth_test.go
  24. 16 5
      DB/core/transaction.go
  25. 24 6
      DB/core/types/codec.go
  26. 28 0
      DB/core/types/codec_test.go
  27. 1 1
      DB/core/types/decimal.go
  28. 299 0
      DB/core/types/stringwidth_test.go
  29. 321 12
      DB/core/types/types.go
  30. 57 35
      DB/core/wal.go
  31. 4 2
      DB/core/wal_recovery.go
  32. 37 11
      DB/db.go
  33. 6 4
      DB/query.go
  34. 72 13
      DB/types.go
  35. 87 0
      DB/types_test.go
  36. 21 1
      Makefile
  37. 7 5
      README.md
  38. 2 4
      analyzer/indexcheck.go
  39. 117 18
      analyzer/main.go
  40. 103 2
      analyzer/main_test.go
  41. 37 6
      analyzer/web/index.html
  42. 262 11
      cmd/vrtxsqlweb/index.html
  43. BIN
      demo/.DS_Store
  44. 7 1
      demo/bench_10m/main.go
  45. 165 103
      demo/lock_table_test/main.go
  46. 1 1
      docs/INDEX_TYPES_AND_ORM_GUIDE.md
  47. 1 1
      docs/OPERATION_TUTORIAL.md
  48. 156 0
      migrate/abort_test.go
  49. 54 15
      migrate/mysql.go
  50. 69 0
      migrate/roundtrip_test.go
  51. 63 2
      migrate/run.go
  52. 40 9
      migrate/sql.go
  53. 29 9
      migrate/sqlite.go
  54. 127 26
      migrate/sqltype.go
  55. 188 24
      migrate/sqltype_test.go
  56. 67 7
      migrate/types.go
  57. 137 0
      migrate/validate.go
  58. 335 0
      migrate/validate_test.go
  59. 26 6
      migrate/value.go
  60. 96 0
      migrate/value_test.go
  61. 26 11
      migrate/vrtx.go
  62. 100 0
      migrate/widerow.go
  63. 223 0
      migrate/widerow_test.go

BIN
.DS_Store


+ 13 - 0
.gitignore

@@ -1,6 +1,19 @@
 # 数据库文件(测试 / 运行时生成)
 *.db
 
+# analyzer 分析缓存(跟着 .db 一起生成,不要进仓库)
+*.analyzer
+
 # 编译产物
 *.exe
 *.exe~
+/vrtxsql
+/vrtxsqlweb
+/analyzer/analyzer
+
+# 运行日志(demo 里的 .log)
+*.log
+
+# 系统 / 临时文件
+.DS_Store
+tmp_*

+ 281 - 0
DB/concurrent_test.go

@@ -0,0 +1,281 @@
+package DB
+
+import (
+	"fmt"
+	"path/filepath"
+	"sync"
+	"testing"
+)
+
+// ccRow 并发用例的行: 只有一列, 免得用例本身被别的机制干扰。
+type ccRow struct {
+	BaseModel
+	Name string `vrtx:"maxlen:32"`
+}
+
+// TestConcurrentInsertAndCommit 一个 goroutine 写、另一个 goroutine 提交 —— 这是
+// 「自动提交开着 + 业务自己再显式 Commit」以及各种后台提交线程的常见写法, 也是旧实现
+// 会稳定 panic 的那条路。
+//
+// 旧实现的窗口: DB.Commit 先 CollectDirty —— 把脏页【移出】缓冲池, 再走 WAL
+// (writeWALPages + 两次 fsync) 和逐页回写。从移出到回写完成这段时间里, 那些页
+// 既不在缓存、也还没落到自己的位置: 并发的读一旦缓存未命中就回源, 要么读到旧内容,
+// 要么 (页在本会话刚分配、盘上还从没写过) 读到全零 —— 而 btree 把全零页当成
+// 「EntryLen == 0 的节点」, 直接在 btPID 里切片越界 panic。
+//
+// 修复后脏页在提交期间【留在缓存】, 只有等它确实回写成功、且期间没被再改过, 才转成
+// 干净页。因此下面这个用例必须整轮跑完且一行不少。
+func TestConcurrentInsertAndCommit(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "concurrent.db")
+	db, err := Open(dbPath)
+	if err != nil {
+		t.Fatalf("打开 %s: %v", dbPath, err)
+	}
+	defer db.Close()
+
+	tbl, err := UseTable[ccRow](db, "cc")
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+	// 关掉防抖定时器: 提交时机完全由下面那个 goroutine 决定, 用例才可复现。
+	db.SetAutoCommitDelay(0)
+
+	const rows = 3000
+
+	// 写入侧与提交侧各自把 panic / error 交回来: goroutine 里 panic 会直接带走整个
+	// 进程, 测试根本跑不到断言 —— 必须在 goroutine 内 recover 才能把它变成失败。
+	fail := make(chan any, 1)
+	report := func(v any) {
+		select {
+		case fail <- v:
+		default:
+		}
+	}
+
+	var writer sync.WaitGroup
+	writer.Add(1)
+	go func() {
+		defer writer.Done()
+		defer func() {
+			if r := recover(); r != nil {
+				report(fmt.Errorf("写入 goroutine panic: %v", r))
+			}
+		}()
+		for i := 0; i < rows; i++ {
+			if err := tbl.Insert(&ccRow{Name: fmt.Sprintf("n%06d", i)}); err != nil {
+				report(fmt.Errorf("第 %d 行写入失败: %w", i+1, err))
+				return
+			}
+		}
+	}()
+
+	stop := make(chan struct{})
+	var committer sync.WaitGroup
+	committer.Add(1)
+	go func() {
+		defer committer.Done()
+		defer func() {
+			if r := recover(); r != nil {
+				report(fmt.Errorf("提交 goroutine panic: %v", r))
+			}
+		}()
+		for {
+			select {
+			case <-stop:
+				return
+			default:
+			}
+			if err := db.Commit(); err != nil {
+				report(fmt.Errorf("提交失败: %w", err))
+				return
+			}
+		}
+	}()
+
+	writer.Wait()
+	close(stop)
+	committer.Wait()
+
+	select {
+	case v := <-fail:
+		t.Fatal(v)
+	default:
+	}
+
+	// 行数必须一行不少: 少的那部分正是「提交与写入重叠时被吞掉 / 被当成干净页丢掉」的。
+	if err := db.Commit(); err != nil {
+		t.Fatalf("收尾提交: %v", err)
+	}
+	n, err := tbl.Count()
+	if err != nil {
+		t.Fatalf("查行数: %v", err)
+	}
+	if n != rows {
+		t.Fatalf("行数应当 %d, 实际 %d", rows, n)
+	}
+
+	// 关掉再开一遍: 确认提交出去的东西真的在盘上, 而不只是活在缓存里。
+	if err := db.Close(); err != nil {
+		t.Fatalf("关闭: %v", err)
+	}
+	reopened, err := Open(dbPath)
+	if err != nil {
+		t.Fatalf("重开: %v", err)
+	}
+	defer reopened.Close()
+	tbl2, err := UseTable[ccRow](reopened, "cc")
+	if err != nil {
+		t.Fatalf("重开表: %v", err)
+	}
+	if n2, err := tbl2.Count(); err != nil || n2 != rows {
+		t.Fatalf("重开后行数应当 %d, 实际 %d (err=%v)", rows, n2, err)
+	}
+}
+
+// TestWritePageDirectKeepsCacheCoherent 直写盘之后缓存副本必须失效 —— 且失效动作要
+// 排在【落盘之后】。
+//
+// 反了的话, WritePageDirect 先删缓存再写盘, 中间这一小段页同样"两头都不在": 对一页
+// 本会话刚分配、盘上从没写过的页, 并发读会拿到全零。
+func TestWritePageDirectKeepsCacheCoherent(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "direct.db")
+	db, err := Open(dbPath)
+	if err != nil {
+		t.Fatalf("打开 %s: %v", dbPath, err)
+	}
+	defer db.Close()
+
+	tbl, err := UseTable[ccRow](db, "direct")
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+	db.SetAutoCommitDelay(0) // 脏页留在缓存里, 好让下面读到的是缓存副本
+
+	for i := 0; i < 50; i++ {
+		if err := tbl.Insert(&ccRow{Name: fmt.Sprintf("d%06d", i)}); err != nil {
+			t.Fatalf("第 %d 行写入失败: %v", i+1, err)
+		}
+	}
+
+	// 有脏页在缓存里时反复"直写盘 + 读回", 全程不允许出现读到全零页的情况。
+	// 这里直接调 ORM 的 Commit (内部就是 WAL 直写回填), 再立刻按 pk 读回。
+	for round := 0; round < 20; round++ {
+		if err := db.Commit(); err != nil {
+			t.Fatalf("第 %d 轮提交: %v", round+1, err)
+		}
+		got, err := tbl.Find(1)
+		if err != nil {
+			t.Fatalf("第 %d 轮按主键读回: %v", round+1, err)
+		}
+		if got == nil || got.Name != "d000000" {
+			t.Fatalf("第 %d 轮读回的内容不对: %+v", round+1, got)
+		}
+	}
+}
+
+// TestConcurrentCommitDoesNotLoseDirtyPages 提交窗口里被再次改脏的页, 不能因为一次
+// 「提交成功」就被顺手标成干净页。
+//
+// ClearAll 那种「提交成功就把整个缓存标干净」的做法, 对并发写入是致命的: 窗口期内
+// 新弄脏的页没有被写进本笔 WAL, 却被抹掉脏标记 —— 缓存里是新值, 盘上是旧值, 而引擎
+// 以为两边一致; 该页一旦被淘汰, 新值就永久消失。
+func TestConcurrentCommitDoesNotLoseDirtyPages(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "dirty.db")
+	db, err := Open(dbPath)
+	if err != nil {
+		t.Fatalf("打开 %s: %v", dbPath, err)
+	}
+	defer db.Close()
+
+	tbl, err := UseTable[ccRow](db, "dirty")
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+	db.SetAutoCommitDelay(0)
+
+	// 先把表建出来并落一次盘, 让后面的写入都发生在"已提交结构"之上。
+	if err := tbl.Insert(&ccRow{Name: "base"}); err != nil {
+		t.Fatalf("写入基线行: %v", err)
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("基线提交: %v", err)
+	}
+
+	// 写入侧持续改同一批行 (Update 会反复弄脏同一批索引页/数据页, 最容易撞上窗口),
+	// 提交侧同时不停提交。
+	const rounds = 400
+	fail := make(chan any, 1)
+	report := func(v any) {
+		select {
+		case fail <- v:
+		default:
+		}
+	}
+
+	stop := make(chan struct{})
+	var committer sync.WaitGroup
+	committer.Add(1)
+	go func() {
+		defer committer.Done()
+		defer func() {
+			if r := recover(); r != nil {
+				report(fmt.Errorf("提交 goroutine panic: %v", r))
+			}
+		}()
+		for {
+			select {
+			case <-stop:
+				return
+			default:
+			}
+			if err := db.Commit(); err != nil {
+				report(fmt.Errorf("提交失败: %w", err))
+				return
+			}
+		}
+	}()
+
+	for i := 1; i <= rounds; i++ {
+		row := &ccRow{Name: fmt.Sprintf("v%06d", i)}
+		if err := tbl.Insert(row); err != nil {
+			report(fmt.Errorf("第 %d 行写入失败: %w", i, err))
+			break
+		}
+	}
+	close(stop)
+	committer.Wait()
+
+	select {
+	case v := <-fail:
+		t.Fatal(v)
+	default:
+	}
+
+	// 收尾: 提交 + 关闭重开。任何"被标成干净但其实没落盘"的页都会在这里现形。
+	if err := db.Commit(); err != nil {
+		t.Fatalf("收尾提交: %v", err)
+	}
+	want := int64(rounds + 1)
+	if n, err := tbl.Count(); err != nil || n != want {
+		t.Fatalf("关闭前行数应当 %d, 实际 %d (err=%v)", want, n, err)
+	}
+
+	if err := db.Close(); err != nil {
+		t.Fatalf("关闭: %v", err)
+	}
+	reopened, err := Open(dbPath)
+	if err != nil {
+		t.Fatalf("重开: %v", err)
+	}
+	defer reopened.Close()
+	tbl2, err := UseTable[ccRow](reopened, "dirty")
+	if err != nil {
+		t.Fatalf("重开表: %v", err)
+	}
+	if n, err := tbl2.Count(); err != nil || n != want {
+		t.Fatalf("重开后行数应当 %d, 实际 %d (err=%v)", want, n, err)
+	}
+	if _, err := tbl2.Find(1); err != nil {
+		t.Fatalf("重开后按主键读回: %v", err)
+	}
+}

+ 16 - 3
DB/convert.go

@@ -151,7 +151,18 @@ func normalizeValue(fv reflect.Value, fd FieldDef) interface{} {
 		return toFloatVal(carrier)
 	case types.FieldType_16byte, types.FieldType_32byte,
 		types.FieldType_64byte, types.FieldType_128byte, types.FieldType_256byte:
-		return toFixedBytes(carrier, int(types.GetSize(fd.FieldType)))
+		// 字符串 / []byte 载体【原样下传】: 补零与"超宽报错"都由 types.EncodeValue
+		// 按列的实际宽度完成 —— 宽度只有一个权威来源 (见 FieldDef.Width)。
+		//
+		// 旧实现先在这里 toFixedBytes 一次, 而那个函数是 copy 进定长缓冲: 超宽部分被
+		// 静默丢掉。`maxlen:10` 的列写 20 字节会成功落库并少掉一半内容, 调用方拿不到
+		// 任何信号 —— 这正是列宽闸门要拦的事, 拦在 ORM 就会绕过它。
+		switch carrier.(type) {
+		case string, []byte:
+			return carrier
+		}
+		// 数值载体 (16byte 兼作裸字节列的旧用法) 仍按列宽做 BigEndian 编码
+		return toFixedBytes(carrier, int(fd.Width()))
 	case types.FieldType_Dynamicbyte, types.FieldType_Blob:
 		// 动态列必须送 []byte: 引擎侧对动态列只做 `value.([]byte)` 断言,
 		// 送 string / 数值会在那里被明确拒绝 (见 core.writeFieldValueAt)。
@@ -638,8 +649,10 @@ func zeroValue(fd FieldDef) interface{} {
 		if types.IsDynamic(fd.FieldType) {
 			return []byte{}
 		}
-		size := int(types.GetSize(fd.FieldType))
-		if size > 0 {
+		// 定长字符串列的零值 = 该列宽度个零字节。宽度必须取列声明 (fd.Width()),
+		// 不是类型默认: `maxlen:1000` 的列按 256 补零会让 EncodeValue 的长度校验失败,
+		// "写零值"这条路直接报错。
+		if size := int(fd.Width()); size > 0 {
 			return make([]byte, size)
 		}
 		return []byte{}

+ 5 - 5
DB/core/EqCondition.go

@@ -7,14 +7,14 @@ type EqCondition struct {
 	Value interface{}
 
 	// Deprecated: 已不参与比较 —— 行级过滤与索引路径都按【列的实际存储类型】编字节
-	// (见 Table.compareTypeFor)。它只剩"值怎么从 interface{} 取出"的提示作用,
+	// (见 Table.compareColTypeFor)。它只剩"值怎么从 interface{} 取出"的提示作用,
 	// 传任何值结果都一样; 保留仅为兼容既有调用方, 新代码传 0 即可。
 	Type types.FieldType
 }
 
 // Eq 构造 "field = value" 条件。
 //
-// 第三个参数 typ 已废弃: 比较一律按列的实际存储类型进行 (见 Table.compareTypeFor),
+// 第三个参数 typ 已废弃: 比较一律按列的实际存储类型进行 (见 Table.compareColTypeFor),
 // 它取任何值结果都一样, 传 types.FieldType(0) 即可。
 // 上面刻意不写成 "Deprecated:" 段落 —— Go 只能标记整个标识符废弃, 写了会把本函数
 // 一起判为废弃, 而本函数还要继续用。
@@ -35,7 +35,7 @@ func (c EqCondition) Match(row []byte, table *Table) bool {
 	if !ok {
 		return false
 	}
-	// 比较类型一律取列的实际存储类型 (声明类型已废弃), 与索引路径编键同源。
-	typ := table.compareTypeFor(fieldIdx)
-	return table.compareValue(row, offset, typ, c.Value)
+	// 比较类型与列级参数一律取列的实际定义 (声明类型已废弃), 与索引路径编键同源。
+	ct := table.compareColTypeFor(fieldIdx)
+	return table.compareValue(row, offset, ct, c.Value)
 }

+ 101 - 6
DB/core/File.go

@@ -1,8 +1,10 @@
 package core
 
 import (
+	"TsDB2/DB/core/page"
 	"TsDB2/DB/core/pool"
 	"os"
+	"sync"
 	"unsafe"
 )
 
@@ -10,6 +12,20 @@ import (
 type File struct {
 	f  *os.File
 	bp *pool.BufferPool // 页面缓冲池 (nil = 未启用)
+
+	// headerMu 保护 HeaderPage (pageID=0) 的【内存字段】及其持久化。
+	//
+	// 头页是整个引擎唯一的全局可变结构: 写入路径要它分配 / 回收页号 (NextFreePage /
+	// FreePageStart / FreePageEnd / FreePageCount), 提交路径要它发布 / 清空 WAL 链指针
+	// (FirstWALPage / LastWALPage) 并推进 NextTxnID。两条路径并发时若不串行化:
+	//   - 两个 goroutine 同时读到同一个 NextFreePage 再各自 +1 → 同一个页号发给两方,
+	//     后写的一方覆盖先写的一方 → 静默丢数据;
+	//   - WriteHeader 在拷贝整张头页时, 另一个 goroutine 正在改字段 → 数据竞争 (崩溃)。
+	// 所有对 HeaderPage 字段的读写都必须落在 headerMu 内。
+	//
+	// 不能把 mutex 放进 HeaderPage 本身: 它按内存布局经 unsafe 直接落盘 (见 WriteHeader /
+	// marshalHeader), 加字段会改变盘上格式。
+	headerMu sync.Mutex
 }
 
 func OpenFile(path string) (*File, error) {
@@ -68,7 +84,7 @@ func (ff *File) SetBufferPoolCapacity(capacity int) {
 	}
 }
 
-// CollectDirty 收集所有脏页快照并清除脏标记 (配合 WAL 使用)
+// CollectDirty 收集所有脏页快照 (配合 WAL 使用; 页不会因此离开缓存, 见 pool.CollectDirty)
 func (ff *File) CollectDirty() []pool.DirtyPage {
 	if ff.bp != nil {
 		return ff.bp.CollectDirty()
@@ -76,6 +92,13 @@ func (ff *File) CollectDirty() []pool.DirtyPage {
 	return nil
 }
 
+// MarkClean 把这批快照页收成干净页 (提交成功后调用, 与 CollectDirty 配对)
+func (ff *File) MarkClean(pages []pool.DirtyPage) {
+	if ff.bp != nil {
+		ff.bp.MarkClean(pages)
+	}
+}
+
 // FlushBufferPool 刷盘 (仅在 Close 时使用)
 func (ff *File) FlushBufferPool() {
 	if ff.bp != nil {
@@ -83,7 +106,7 @@ func (ff *File) FlushBufferPool() {
 	}
 }
 
-// RestoreDirty 提交失败时把脏页快照放回 BufferPool (与 CollectDirty 配对)
+// RestoreDirty 提交失败时兜底: 保证这批页在缓存里仍然是脏页 (与 CollectDirty 配对)
 func (ff *File) RestoreDirty(pages []pool.DirtyPage) {
 	if ff.bp != nil {
 		ff.bp.RestoreDirty(pages)
@@ -121,16 +144,33 @@ func (ff *File) ReadPage(pageID uint32, buf []byte) error {
 	return ff.readPageDisk(pageID, buf)
 }
 
-// WritePageDirect 直接写盘 (绕过 BufferPool, 用于 WAL 回写)
-// WritePageDirect 直接写盘 (绕过 BufferPool, 用于 WAL 回写)
+// WritePageDirect 直接写盘 (绕过 BufferPool)
 //
-// 因为绕过了缓冲池, 必须让缓存中该页的旧副本立即失效 —— 否则后续 ReadPage 会命中
+// 因为绕过了缓冲池, 必须让缓存中该页的旧副本失效 —— 否则后续 ReadPage 会命中
 // 与磁盘不一致的陈旧内容。典型场景: 页从空闲链复用时缓存里还留有旧副本, 被当作
 // WAL 页直写后该副本已失真; 整链回收若只同步链尾页, 就会漏掉链内这些页。
+//
+// 失效必须排在【落盘之后】。反过来的话, 从删缓存到写完盘这一小段里, 这一页同样
+// "两头都不在": 对一页本会话刚分配、盘上还从没写过的页, 并发读会拿到全零 (文件还
+// 没长到那个偏移时直接读出错), 而 btree 把全零页当成 EntryLen==0 的节点, 随后就在
+// btPID 里切片越界。先落盘再失效, 最坏也只是读到"马上要被覆盖的旧内容", 不会两头皆空。
 func (ff *File) WritePageDirect(pageID uint32, buf []byte) error {
+	if err := ff.writePageDisk(pageID, buf); err != nil {
+		return err
+	}
 	if ff.bp != nil {
 		ff.bp.DiscardPage(pageID)
 	}
+	return nil
+}
+
+// WritePageHome 把页镜像写回它自己的位置 (WAL 提交时的"回填"步骤)。
+//
+// 与 WritePageDirect 的唯一区别是【不动缓存副本】: 这份镜像是从缓存快照里来的,
+// 缓存里那一份的内容只会等于它或新于它, 无差别失效反而会丢掉提交窗口里写入线程刚
+// 改出来的新内容 (并发写 + 提交时就是如此)。页的脏/干净状态交给提交方用
+// File.MarkClean 按版本号统一收尾 —— 没被改过的转干净, 改过的继续留脏。
+func (ff *File) WritePageHome(pageID uint32, buf []byte) error {
 	return ff.writePageDisk(pageID, buf)
 }
 
@@ -166,13 +206,68 @@ func (ff *File) readPageDisk(pageID uint32, buf []byte) error {
 // -----------------------------------------------------------------------------------
 // ✅ 头读写(固定4K)
 // -----------------------------------------------------------------------------------
-func (ff *File) WriteHeader(h *HeaderPage) error {
+
+// writeHeaderLocked 把头页 (4K) 落盘。调用方必须已持有 headerMu。
+// 与 WriteHeader 的区别就是不重复加锁 —— 供 withHeader 内部使用, 避免自死锁。
+func (ff *File) writeHeaderLocked(h *HeaderPage) error {
 	var buf [PageSize4K]byte
 	copy(buf[:], (*[PageSize4K]byte)(unsafe.Pointer(h))[:])
 	_, err := ff.f.WriteAt(buf[:], 0)
 	return err
 }
 
+// withHeader 在 headerMu 保护下执行 fn。
+//
+// fn 内可以安全地读 / 改 HeaderPage 的任何字段; 若还需要把头页落盘, 必须调用
+// writeHeaderLocked (不能调用 WriteHeader —— 那会二次获取 headerMu, 直接死锁)。
+//
+// fn 内【不要】调用会同步触发提交的动作 (典型是经 BufferPool 缓存的 WritePage: 脏页
+// 超过阈值时它会在当前 goroutine 里直接调 Commit, 而 Commit 的 WAL 写入又要 headerMu):
+// 那会自死锁。需要这种写页请放到锁外, 见 allocPage。
+func (ff *File) withHeader(fn func()) {
+	ff.headerMu.Lock()
+	defer ff.headerMu.Unlock()
+	fn()
+}
+
+// WriteHeader 线程安全地把头页 (4K) 落盘。
+func (ff *File) WriteHeader(h *HeaderPage) error {
+	ff.headerMu.Lock()
+	defer ff.headerMu.Unlock()
+	return ff.writeHeaderLocked(h)
+}
+
+// allocPage 线程安全地分配一个页号: 在 headerMu 内摘空闲链 / 递增游标并推进头页,
+// 随后把头页落盘。
+//
+// erase 决定是否把该页擦成空白页 (写缓存, 延迟落盘):
+//   - 普通写入路径传 true: 复用的空闲页可能残留旧页类型, 必须擦干净, 否则按页号
+//     读回来会被当成合法但类型错误的页。
+//   - WAL 打包路径传 false: WAL 页紧接着就在内存里被整页重造 (16KB 全写), 不需要
+//     擦页; 更要紧的是此时正处在 Commit 内部 (持有 commitMu), 而擦页会走
+//     File.WritePage → BufferPool.WritePage, 脏页超阈值时同步回调 autoCommit →
+//     再进一次 Commit → 卡死在已持有的 commitMu 上 (fatal: all goroutines are asleep)。
+//
+// 为什么 headerMu 只罩住"选号 + 推进头页 + 头页落盘", 擦页放在锁外: 擦页可能重入提交,
+// 而提交的 WAL 打包又要 headerMu —— 锁内擦页同样是自死锁。
+func (ff *File) allocPage(h *HeaderPage, erase bool) uint32 {
+	ff.headerMu.Lock()
+	// writePage 传 nil: 只推进头页游标, 不做任何可能重入提交的写。
+	pid := h.newPageWithReader(ff.readPageDisk, nil)
+	_ = ff.writeHeaderLocked(h)
+	ff.headerMu.Unlock()
+
+	if !erase {
+		return pid
+	}
+
+	// 锁外擦页: 页头只留自述页号 (Type=0、UseSize=页头长), 其余字节全 0。
+	clean := make([]byte, PageSize)
+	page.HdrOf(clean).Init(0, pid)
+	_ = ff.WritePage(pid, clean)
+	return pid
+}
+
 func (ff *File) ReadHeader() (*HeaderPage, error) {
 	buf := make([]byte, PageSize4K)
 	_, err := ff.f.ReadAt(buf, 0)

+ 5 - 5
DB/core/GeCondition.go

@@ -7,14 +7,14 @@ type GeCondition struct {
 	Value interface{}
 
 	// Deprecated: 已不参与比较 —— 行级过滤与索引路径都按【列的实际存储类型】编字节
-	// (见 Table.compareTypeFor)。它只剩"值怎么从 interface{} 取出"的提示作用,
+	// (见 Table.compareColTypeFor)。它只剩"值怎么从 interface{} 取出"的提示作用,
 	// 传任何值结果都一样; 保留仅为兼容既有调用方, 新代码传 0 即可。
 	Type types.FieldType
 }
 
 // Ge 构造 "field >= value" 条件。
 //
-// 第三个参数 typ 已废弃: 比较一律按列的实际存储类型进行 (见 Table.compareTypeFor),
+// 第三个参数 typ 已废弃: 比较一律按列的实际存储类型进行 (见 Table.compareColTypeFor),
 // 它取任何值结果都一样, 传 types.FieldType(0) 即可。
 // 上面刻意不写成 "Deprecated:" 段落 —— Go 只能标记整个标识符废弃, 写了会把本函数
 // 一起判为废弃, 而本函数还要继续用。
@@ -36,7 +36,7 @@ func (c GeCondition) Match(row []byte, table *Table) bool {
 	if !ok {
 		return false
 	}
-	// 比较类型一律取列的实际存储类型 (声明类型已废弃), 与索引路径编键同源。
-	typ := table.compareTypeFor(fieldIdx)
-	return table.compareGreaterOrEqual(row, offset, typ, c.Value)
+	// 比较类型与列级参数一律取列的实际定义 (声明类型已废弃), 与索引路径编键同源。
+	ct := table.compareColTypeFor(fieldIdx)
+	return table.compareGreaterOrEqual(row, offset, ct, c.Value)
 }

+ 5 - 5
DB/core/GtCondition.go

@@ -7,14 +7,14 @@ type GtCondition struct {
 	Value interface{}
 
 	// Deprecated: 已不参与比较 —— 行级过滤与索引路径都按【列的实际存储类型】编字节
-	// (见 Table.compareTypeFor)。它只剩"值怎么从 interface{} 取出"的提示作用,
+	// (见 Table.compareColTypeFor)。它只剩"值怎么从 interface{} 取出"的提示作用,
 	// 传任何值结果都一样; 保留仅为兼容既有调用方, 新代码传 0 即可。
 	Type types.FieldType
 }
 
 // Gt 构造 "field > value" 条件。
 //
-// 第三个参数 typ 已废弃: 比较一律按列的实际存储类型进行 (见 Table.compareTypeFor),
+// 第三个参数 typ 已废弃: 比较一律按列的实际存储类型进行 (见 Table.compareColTypeFor),
 // 它取任何值结果都一样, 传 types.FieldType(0) 即可。
 // 上面刻意不写成 "Deprecated:" 段落 —— Go 只能标记整个标识符废弃, 写了会把本函数
 // 一起判为废弃, 而本函数还要继续用。
@@ -36,7 +36,7 @@ func (c GtCondition) Match(row []byte, table *Table) bool {
 	if !ok {
 		return false
 	}
-	// 比较类型一律取列的实际存储类型 (声明类型已废弃), 与索引路径编键同源。
-	typ := table.compareTypeFor(fieldIdx)
-	return table.compareGreater(row, offset, typ, c.Value)
+	// 比较类型与列级参数一律取列的实际定义 (声明类型已废弃), 与索引路径编键同源。
+	ct := table.compareColTypeFor(fieldIdx)
+	return table.compareGreater(row, offset, ct, c.Value)
 }

+ 5 - 5
DB/core/InCondition.go

@@ -7,14 +7,14 @@ type InCondition struct {
 	Values []interface{}
 
 	// Deprecated: 已不参与比较 —— 行级过滤与索引路径都按【列的实际存储类型】编字节
-	// (见 Table.compareTypeFor)。它只剩"值怎么从 interface{} 取出"的提示作用,
+	// (见 Table.compareColTypeFor)。它只剩"值怎么从 interface{} 取出"的提示作用,
 	// 传任何值结果都一样; 保留仅为兼容既有调用方, 新代码传 0 即可。
 	Type types.FieldType
 }
 
 // In 构造 "field IN (values...)" 条件。
 //
-// 第三个参数 typ 已废弃: 比较一律按列的实际存储类型进行 (见 Table.compareTypeFor),
+// 第三个参数 typ 已废弃: 比较一律按列的实际存储类型进行 (见 Table.compareColTypeFor),
 // 它取任何值结果都一样, 传 types.FieldType(0) 即可。
 // 上面刻意不写成 "Deprecated:" 段落 —— Go 只能标记整个标识符废弃, 写了会把本函数
 // 一起判为废弃, 而本函数还要继续用。
@@ -36,10 +36,10 @@ func (c InCondition) Match(row []byte, table *Table) bool {
 	if !ok {
 		return false
 	}
-	// 比较类型一律取列的实际存储类型 (声明类型已废弃), 与索引路径编键同源。
-	typ := table.compareTypeFor(fieldIdx)
+	// 比较类型与列级参数一律取列的实际定义 (声明类型已废弃), 与索引路径编键同源。
+	ct := table.compareColTypeFor(fieldIdx)
 	for _, value := range c.Values {
-		if table.compareValue(row, offset, typ, value) {
+		if table.compareValue(row, offset, ct, value) {
 			return true
 		}
 	}

+ 12 - 8
DB/core/IndexMetaPage.go

@@ -194,10 +194,13 @@ func (im *IndexMetaPage) CreateIndex(indexName string, itype page.IndexType, fie
 		return nil, fmt.Errorf("%w: 索引名 %q 有 %d 字节, 上限 %d",
 			ErrIndexNameTooLong, indexName, len(indexName), IndexNameSize)
 	}
-	fc := byte(len(fieldIDs))
-	if fc == 0 || fc > 16 {
-		return nil, ErrIndexFieldCount
+	// 分量个数闸门。先按 int 判再转 byte: 直接 `byte(len(fieldIDs))` 会在超过 255 时回绕
+	// (例如 257 个份量截成 1), 于是越界的调用反而"通过"了, 之后按错误的分量数建条目。
+	if len(fieldIDs) == 0 || len(fieldIDs) > MaxIndexFields {
+		return nil, fmt.Errorf("%w (本次 %d 个分量, 索引 %q)",
+			ErrIndexFieldCount, len(fieldIDs), indexName)
 	}
+	fc := byte(len(fieldIDs))
 
 	// 复合键要求各分量互不相同。重复列会让 extractIndexKey 永远取不齐分量
 	// (某一分量无人填充), 索引静默变成空索引 —— 写入成功却查不到, 因此直接拒绝。
@@ -535,8 +538,9 @@ func (im *IndexMetaPage) saveMeta(m *IndexMeta) {
 // 复合索引的键 = 按 FieldIDs 顺序, 把各分量字段的原始字节定长拼接。
 //   - 顺序取 FieldIDs 而非行内物理顺序 —— 查询侧据此才能做"列前缀"范围推导:
 //     (a, b) 上的索引既能服务 a=?, 也能服务 a=? AND b>?;
-//   - 每个分量长度 = GetSize(字段类型), 与行内编码完全一致, 因此拼接键是定长的,
-//     字节序比较等价于"先比 a 再比 b"的字典序;
+//   - 每个分量长度 = FieldSize(字段类型, 列级参数), 与行内编码完全一致, 因此拼接键是定长的,
+//     字节序比较等价于"先比 a 再比 b"的字典序。宽度必须带列级参数 (字符串列的宽度码):
+//     按类型默认档切键会把宽字符串列切成半截, 后面那个分量整体读错位;
 //   - 任一分量在该行版本里取不到 (列被 Drop / schema 版本不匹配) → 返回 nil,
 //     该索引跳过这一行, 与单列索引"取不到键就跳过"的既有语义一致。
 func extractIndexKey(rowData []byte, meta *IndexMeta, fields []types.FieldInfo, activeVer byte, versions []SchemaVersion) []byte {
@@ -571,8 +575,8 @@ func extractIndexKey(rowData []byte, meta *IndexMeta, fields []types.FieldInfo,
 		if int(idx) >= len(fields) {
 			return nil
 		}
-		sz := getFieldSize(fields[idx].FieldType)
-		if sz == 0 { // 未知类型: 长度算不出来, 位图定位会错, 直接放弃该行
+		sz := fields[idx].Width()
+		if sz == 0 { // 未知类型/非法列参数: 长度算不出来, 位图定位会错, 直接放弃该行
 			return nil
 		}
 		rowSize += int(sz)
@@ -592,7 +596,7 @@ func extractIndexKey(rowData []byte, meta *IndexMeta, fields []types.FieldInfo,
 		if fi >= len(fields) {
 			return nil
 		}
-		size := int(getFieldSize(fields[fi].FieldType))
+		size := int(fields[fi].Width())
 		for k := 0; k < n; k++ {
 			if int(meta.FieldIDs[k]) != fi {
 				continue

+ 5 - 5
DB/core/LeCondition.go

@@ -7,14 +7,14 @@ type LeCondition struct {
 	Value interface{}
 
 	// Deprecated: 已不参与比较 —— 行级过滤与索引路径都按【列的实际存储类型】编字节
-	// (见 Table.compareTypeFor)。它只剩"值怎么从 interface{} 取出"的提示作用,
+	// (见 Table.compareColTypeFor)。它只剩"值怎么从 interface{} 取出"的提示作用,
 	// 传任何值结果都一样; 保留仅为兼容既有调用方, 新代码传 0 即可。
 	Type types.FieldType
 }
 
 // Le 构造 "field <= value" 条件。
 //
-// 第三个参数 typ 已废弃: 比较一律按列的实际存储类型进行 (见 Table.compareTypeFor),
+// 第三个参数 typ 已废弃: 比较一律按列的实际存储类型进行 (见 Table.compareColTypeFor),
 // 它取任何值结果都一样, 传 types.FieldType(0) 即可。
 // 上面刻意不写成 "Deprecated:" 段落 —— Go 只能标记整个标识符废弃, 写了会把本函数
 // 一起判为废弃, 而本函数还要继续用。
@@ -35,7 +35,7 @@ func (c LeCondition) Match(row []byte, table *Table) bool {
 	if !ok {
 		return false
 	}
-	// 比较类型一律取列的实际存储类型 (声明类型已废弃), 与索引路径编键同源。
-	typ := table.compareTypeFor(fieldIdx)
-	return table.compareLessOrEqual(row, offset, typ, c.Value)
+	// 比较类型与列级参数一律取列的实际定义 (声明类型已废弃), 与索引路径编键同源。
+	ct := table.compareColTypeFor(fieldIdx)
+	return table.compareLessOrEqual(row, offset, ct, c.Value)
 }

+ 2 - 3
DB/core/LikeCondition.go

@@ -1,7 +1,6 @@
 package core
 
 import (
-	"TsDB2/DB/core/types"
 	"strings"
 )
 
@@ -35,8 +34,8 @@ func (c LikeCondition) Match(row []byte, table *Table) bool {
 	}
 	fieldInfo := table.Field[fieldIdx]
 
-	// 读取字符串值
-	size := int(types.GetSize(fieldInfo.FieldType))
+	// 读取字符串值; 宽度取列声明的宽度 (含字符串宽度码), 否则宽字符串列会被切短
+	size := int(fieldInfo.Width())
 	if size <= 0 || int(offset)+size > len(row) {
 		return false
 	}

+ 5 - 5
DB/core/LtCondition.go

@@ -7,14 +7,14 @@ type LtCondition struct {
 	Value interface{}
 
 	// Deprecated: 已不参与比较 —— 行级过滤与索引路径都按【列的实际存储类型】编字节
-	// (见 Table.compareTypeFor)。它只剩"值怎么从 interface{} 取出"的提示作用,
+	// (见 Table.compareColTypeFor)。它只剩"值怎么从 interface{} 取出"的提示作用,
 	// 传任何值结果都一样; 保留仅为兼容既有调用方, 新代码传 0 即可。
 	Type types.FieldType
 }
 
 // Lt 构造 "field < value" 条件。
 //
-// 第三个参数 typ 已废弃: 比较一律按列的实际存储类型进行 (见 Table.compareTypeFor),
+// 第三个参数 typ 已废弃: 比较一律按列的实际存储类型进行 (见 Table.compareColTypeFor),
 // 它取任何值结果都一样, 传 types.FieldType(0) 即可。
 // 上面刻意不写成 "Deprecated:" 段落 —— Go 只能标记整个标识符废弃, 写了会把本函数
 // 一起判为废弃, 而本函数还要继续用。
@@ -36,7 +36,7 @@ func (c LtCondition) Match(row []byte, table *Table) bool {
 	if !ok {
 		return false
 	}
-	// 比较类型一律取列的实际存储类型 (声明类型已废弃), 与索引路径编键同源。
-	typ := table.compareTypeFor(fieldIdx)
-	return table.compareLess(row, offset, typ, c.Value)
+	// 比较类型与列级参数一律取列的实际定义 (声明类型已废弃), 与索引路径编键同源。
+	ct := table.compareColTypeFor(fieldIdx)
+	return table.compareLess(row, offset, ct, c.Value)
 }

+ 307 - 93
DB/core/Table.go

@@ -46,10 +46,10 @@ type SchemaVersion struct {
 }
 type TableInfo struct {
 	Name             [TableNameSize]byte
-	Flag             byte   // 表的状态是否被删除
-	FieldCount       byte   // 当前字段的总数量(包含已删除,永不减少)byte=255上限
-	ActiveFieldCount byte   // 实际活跃字段数量(不包含已删除)
-	_                byte   // 1 显式对齐填充 (TotalRows 需要 8 字节对齐)
+	Flag             byte // 表的状态是否被删除
+	FieldCount       byte // 当前字段的总数量(包含已删除,永不减少)byte=255上限
+	ActiveFieldCount byte // 实际活跃字段数量(不包含已删除)
+	_                byte // 1 显式对齐填充 (TotalRows 需要 8 字节对齐)
 
 	// HeapPageCount 本表堆链 (FirstHeap→LastHeap) 上的页数。
 	//
@@ -242,7 +242,7 @@ func (table *Table) CreateIndex(idxName string, typ page.IndexType, fields []byt
 			return nil, fmt.Errorf("%w (索引 %q 的第 %d 个分量)", ErrIndexDynamicField, idxName, i+1)
 		}
 		if typ == page.IndexTypeCompose {
-			if w := getFieldSize(ft); w != 8 {
+			if w := table.Field[fi].Width(); w != 8 {
 				return nil, fmt.Errorf("%w (索引 %q 的第 %d 个分量是 %s, 宽度 %d)",
 					ErrIndexComposeWidth, idxName, i+1, types.TypeNameOf(ft), w)
 			}
@@ -381,11 +381,11 @@ func (table *Table) BuildIndexKey(idxName string, values ...interface{}) ([]byte
 		if types.IsDynamic(ft) {
 			return nil, fmt.Errorf("索引 %q 的第 %d 个分量是动态列, 不能作为索引键", idxName, k+1)
 		}
-		enc, err := encodeIndexKeySafeWithParam(values[k], ft, table.fieldScale(fi))
+		enc, err := encodeIndexKeySafeWithParam(values[k], ft, table.fieldParam(fi))
 		if err != nil {
 			return nil, fmt.Errorf("索引 %q 的第 %d 个分量: %w", idxName, k+1, err)
 		}
-		if want := int(getFieldSize(ft)); len(enc) != want {
+		if want := int(table.Field[fi].Width()); len(enc) != want {
 			return nil, fmt.Errorf("索引 %q 的第 %d 个分量: 编码 %d 字节, 列宽 %d 字节",
 				idxName, k+1, len(enc), want)
 		}
@@ -419,13 +419,73 @@ func (table *Table) findFieldIndex(name string) int {
 	return -1
 }
 
-// getFieldSize 获取字段大小。
+// fieldWidth 取某列的行内宽度 (字节) —— 引擎内所有行偏移计算的唯一入口。
 //
-// 不再需要 & 0x7F: 删除标记已迁到 FieldInfo.Flag (见 types.FlagDeleted), FieldType
-// 现在就是纯粹的类型值 —— 旧实现必须在这里清标记位, 漏一处就让已删除列的宽度变成 0,
-// 整行字段静默错位。
-func getFieldSize(ft types.FieldType) uint16 {
-	return types.GetSize(ft)
+// 为什么不能直接 types.GetSize(f.FieldType): 字符串列的行内宽度由【列自己声明】
+// (Flag 低 5 位的宽度码, 上限 types.MaxInlineColumnWidth), 只看类型会一律算成 256,
+// 于是该列之后的所有列都从错位偏移解码。这里统一经 FieldInfo.Width()。
+//
+// 返回 0 表示索引越界或列级参数非法 —— 调用方必须当错误处理: 0 宽度会让整行错位。
+func (table *Table) fieldWidth(masterIdx int) uint16 {
+	if masterIdx < 0 || masterIdx >= len(table.Field) {
+		return 0
+	}
+	return table.Field[masterIdx].Width()
+}
+
+// MaxPhysicalRowData 单行 rowData 的物理上限 (字节) —— 一行必须能装进一个 DataPage。
+//
+//	16384 (页) - 40 (DataPage 头) - 4 (行长度前缀) - 8 (storeRow 里的 pkID) = 16332
+//
+// 它与建列闸门 types.MaxRowDataSize (8171, 一页两行) 是两件事:
+//   - MaxRowDataSize 是设计政策, 在 CreateTable / AddField 处执行, 保证一页至少装两行;
+//     它卡的是【整行占用】= 4 + 8 + 这里的 rowData, 所以比这道物理墙紧得多 (8159 / 16332);
+//   - MaxPhysicalRowData 是物理墙, 在写入时兜底 —— 超过它的行字节流会写坏 DataPage,
+//     必须有第二道网拦住"建列闸门之前的旧表"与"schema 被外部改坏"这两种情况。
+const MaxPhysicalRowData = int(PageSize) - int(page.DataPageHdrSize) - 4 - 8
+
+// validateFieldsForSchema 校验一组列定义能否构成合法 schema:
+// 列名长度可落盘 + 列级参数自洽 + 行宽不超建列闸门。
+//
+// 校验落在 core 而不是只留在 ORM / migrate: schema 也能从 SQL 或低层 API 直接建出来,
+// 只有拦在真正写 schema 的地方才拦得住。
+func validateFieldsForSchema(fields []types.FieldInfo) error {
+	for _, f := range fields {
+		// 列名长度: schema 里是 [nameLen(1B)][name...] (见 serializeSchema / readSchema)。
+		// 长度为 0 或 >255 都不能落盘 —— 0 会让解析侧把下一个字段的类型字节当名字,
+		// >255 会让 nameLen 截断并让其后所有字段整体错位。都不是"名字有瑕疵", 是整表错乱。
+		if len(f.FieldName) == 0 {
+			return fmt.Errorf("%w: 列名长度为 0 (schema 里列名长度是 1 字节, 空名字无法落盘)",
+				types.ErrValueRange)
+		}
+		if len(f.FieldName) > FieldNameSize {
+			return fmt.Errorf("%w: 列名 %q 有 %d 字节, 上限 %d 字节 "+
+				"(schema 里列名长度是 1 字节, 超长会截断并让后续列整体错位)",
+				types.ErrValueRange, f.FieldName, len(f.FieldName), FieldNameSize)
+		}
+		if err := types.ValidateColumnParam(f.FieldType, f.Param()); err != nil {
+			return fmt.Errorf("列 %q: %w", f.FieldName, err)
+		}
+	}
+	return types.CheckRowSize(fields)
+}
+
+// nextSchemaVersion 返回下一个可用的 schema 版本号。
+//
+// schemaVer 是每行行首的 1 字节 (getVersionedOffset / rowDataSizeForVersion 都按它取版本),
+// 版本条数也是 1 字节落盘 (serializeSchema 的 verCount)。到 0xFF 再自增就会回绕成 0:
+// 0 不是任何已知版本, getVersion 返回 nil → 该行按"未知版本"解析, NULL 位图取不到,
+// 所有列静默读成 0 / NULL。宁可在演进这一步拒绝, 让用户重建表。
+//
+// 每次 AddField / DropField 都会新增一版, 因此这道闸门也限定了"一张表的 schema
+// 最多能演进多少次"。
+func (table *Table) nextSchemaVersion() (byte, error) {
+	if table.activeVersion >= 0xFF || len(table.versions) >= 0xFF {
+		return 0, fmt.Errorf("schema 版本数已达上限 %d (activeVer=%d, 已存 %d 版): "+
+			"版本号是行首 1 字节, 再演进会回绕并让旧行解析错位; 请重建该表",
+			0xFF, table.activeVersion, len(table.versions))
+	}
+	return table.activeVersion + 1, nil
 }
 
 // getVersion 获取指定版本的 schema 定义
@@ -475,7 +535,7 @@ func (table *Table) getVersionedOffset(rowData []byte, masterFieldIdx int) (uint
 			}
 			return offset, true
 		}
-		offset += getFieldSize(table.Field[idx].FieldType)
+		offset += table.Field[idx].Width()
 	}
 	return 0, false
 }
@@ -525,7 +585,7 @@ func (table *Table) rowDataSizeForVersion(ver byte) int {
 	if sv != nil {
 		size += types.NullBitmapLen(len(sv.FieldIndices)) // NULL 位图 (行尾)
 		for _, idx := range sv.FieldIndices {
-			size += int(getFieldSize(table.Field[idx].FieldType))
+			size += int(table.Field[idx].Width())
 		}
 	}
 	return size
@@ -600,9 +660,11 @@ func (table *Table) pickVersionForUpdate(oldRowData []byte, values []types.Field
 	return oldVer
 }
 
-// maxCompareInlineSize 字段比较时使用的栈缓冲上界 == 最宽的定长字段 (256byte)。
-// 超过它的类型会退化成堆分配 (不会 panic, 但新增更宽的类型时值得注意)。
-const maxCompareInlineSize = 256
+// maxCompareInlineSize 字段比较时使用的栈缓冲上界 == 最宽的行内定长列。
+//
+// 取自 types.MaxInlineColumnWidth 而不是写死 256: 字符串列可以用宽度码声明到 2048 字节,
+// 缓冲若还停在 256, 宽字符串列的每次比较都会走堆分配。
+const maxCompareInlineSize = types.MaxInlineColumnWidth
 
 // ======================== 行内字段比较 ========================
 //
@@ -618,13 +680,13 @@ const maxCompareInlineSize = 256
 
 // compareFieldBytes 比较行内字段与给定值, 返回 (cmp, ok); ok=false 表示无法比较。
 //
-// typ 必须是【列的实际存储类型】(调用方经 compareTypeFor 取, 与索引路径编键同源),
-// 否则编码出来的字节与行内不同源, 比较结果没有意义。
+// ct 必须是【列的实际存储类型 + 列级参数】(调用方经 compareColTypeFor 取, 与索引路径
+// 编键同源), 否则编码出来的字节与行内不同源, 比较结果没有意义。
 // ok=false 的来源: 字段越界、动态列解引用失败、值无法按该类型编码。
 // 前两种按"不匹配"处理是正确的; 第三种是调用方 bug —— 索引路径会显式报错
 // (见 encodeIndexKeySafe), 行级过滤按不匹配处理, 至少不会污染数据。
-func (table *Table) compareFieldBytes(row []byte, offset uint16, typ types.FieldType, value interface{}) (int, bool) {
-	if types.IsDynamic(typ) {
+func (table *Table) compareFieldBytes(row []byte, offset uint16, ct colType, value interface{}) (int, bool) {
+	if types.IsDynamic(ct.typ) {
 		if int(offset)+types.DynamicRefSize > len(row) {
 			return 0, false
 		}
@@ -643,7 +705,7 @@ func (table *Table) compareFieldBytes(row []byte, offset uint16, typ types.Field
 		return 0, false
 	}
 
-	size := int(getFieldSize(typ))
+	size := int(ct.width())
 	if size == 0 || int(offset)+size > len(row) {
 		return 0, false
 	}
@@ -651,11 +713,11 @@ func (table *Table) compareFieldBytes(row []byte, offset uint16, typ types.Field
 
 	// decimal 的 scale 存在行内, 条件值的 scale 可能与行内不同 (1.5 与 1.50 的系数
 	// 分别是 15 和 150), 系数直接 memcmp 会得出错误结论 → 走数值比较。
-	if typ == types.FieldType_Decimal {
+	if ct.typ == types.FieldType_Decimal {
 		return table.compareDecimalBytes(stored, value)
 	}
 
-	// 编码到栈缓冲再比较 (定长字段最大 256 字节; 超过则退化为堆分配, 不 panic)
+	// 编码到栈缓冲再比较 (最宽列 2048 字节; 更宽的类型退化为堆分配, 不 panic)
 	var stack [maxCompareInlineSize]byte
 	var buf []byte
 	if size <= len(stack) {
@@ -663,7 +725,9 @@ func (table *Table) compareFieldBytes(row []byte, offset uint16, typ types.Field
 	} else {
 		buf = make([]byte, size)
 	}
-	if err := types.EncodeValue(typ, 0, value, buf); err != nil {
+	// param 必须一起传: 字符串列的宽度码决定 EncodeValue 期望的 buf 长度,
+	// 传 0 会让"按类型默认 256 编"与"按声明 2048 存"对不上 → 该列静默全不匹配。
+	if err := types.EncodeValue(ct.typ, ct.param, value, buf); err != nil {
 		return 0, false
 	}
 	return bytes.Compare(stored, buf), true
@@ -688,55 +752,71 @@ func (table *Table) compareDecimalBytes(stored []byte, value interface{}) (int,
 	return types.DecimalFromBytes(stored).Cmp(d), true
 }
 
-// compareTypeFor 决定行级过滤比较时到底用哪个类型 —— 以【列的实际存储类型】为准。
+// colType 一列"编码/比较所需的全部类型信息": 类型值 + 列级参数 (scale / 宽度码)。
+//
+// 打包成一个值传递, 而不是把 typ 与 param 拆成两个形参: 两者必须一起走 —— 只传类型
+// 会出现"按 256 字节编、按 2048 字节存"这类不一致, 而它表现为整列静默不匹配或整行
+// 错位, 都不是编译错误。打包后"忘了带参数"这件事在签名上就写不出来。
+type colType struct {
+	typ   types.FieldType
+	param byte
+}
+
+// width 该列的行内宽度 (字节); 0 表示参数非法。
+func (c colType) width() uint16 { return types.FieldSize(c.typ, c.param) }
+
+// compareColTypeFor 取出行级过滤比较要用的列信息 —— 以【列的实际存储类型与参数】为准。
 //
 // 为什么不能沿用条件里声明的 Type:
-//   - 索引路径编键用的是列类型 (见 query.go buildKeyLayout / planIndex:
-//     q.table.Field[..].FieldType), 行级过滤若用声明类型, 两条路径就会分叉:
-//     同一列带索引时"声明错类型"仍能命中 (索引键按列类型编), 不带索引时却按
+//   - 索引路径编键用的是列定义 (见 query.go buildKeyLayout / planIndex:
+//     q.table.Field[..].FieldType 与 fieldParam), 行级过滤若用声明类型, 两条路径就会
+//     分叉: 同一列带索引时"声明错类型"仍能命中 (索引键按列定义编), 不带索引时却按
 //     声明类型编出另一种字节 → 静默返回 0 行, 看起来像数据不存在。
 //   - 声明类型因此降级为"值怎么从 interface{} 取出来"的提示: EncodeValue 按 Go
 //     值的 kind 分派, 本身并不依赖它。
 //
+// 列级参数 (字符串宽度码) 同理必须一起取: 宽度决定了"行内那几字节从哪切、切多长",
+// 与是否走索引无关。
+//
 // 删除标记已迁到 FieldInfo.Flag, FieldType 本身不再混入状态位, 因此这里直接返回
 // 即可 —— 旧实现在此清 0x80, 留着标记位会让 IsDynamic 查不到注册项: 动态列会被
 // 当成定长字段, 拿 6 字节堆引用去和值做字节比较。
 //
-// 拿不到字段时返回 0 (未注册类型), compareFieldBytes 判 size==0 后按不匹配处理 ——
-// 调用方已先判过 fieldIdx < 0, 这里只是兜底。
-func (table *Table) compareTypeFor(fieldIdx int) types.FieldType {
+// 拿不到字段时返回零值 (未注册类型 + 参数 0), compareFieldBytes 判 width()==0 后按
+// 不匹配处理 —— 调用方已先判过 fieldIdx < 0, 这里只是兜底。
+func (table *Table) compareColTypeFor(fieldIdx int) colType {
 	if fieldIdx < 0 || fieldIdx >= len(table.Field) {
-		return 0
+		return colType{}
 	}
-	return table.Field[fieldIdx].FieldType
+	return colType{table.Field[fieldIdx].FieldType, table.Field[fieldIdx].Param()}
 }
 
 // compareValue 比较字段值是否相等
-func (table *Table) compareValue(row []byte, offset uint16, typ types.FieldType, value interface{}) bool {
-	c, ok := table.compareFieldBytes(row, offset, typ, value)
+func (table *Table) compareValue(row []byte, offset uint16, ct colType, value interface{}) bool {
+	c, ok := table.compareFieldBytes(row, offset, ct, value)
 	return ok && c == 0
 }
 
 // compareGreater 比较字段值是否大于给定值
-func (table *Table) compareGreater(row []byte, offset uint16, typ types.FieldType, value interface{}) bool {
-	c, ok := table.compareFieldBytes(row, offset, typ, value)
+func (table *Table) compareGreater(row []byte, offset uint16, ct colType, value interface{}) bool {
+	c, ok := table.compareFieldBytes(row, offset, ct, value)
 	return ok && c > 0
 }
 
 // compareLess 比较字段值是否小于给定值
-func (table *Table) compareLess(row []byte, offset uint16, typ types.FieldType, value interface{}) bool {
-	c, ok := table.compareFieldBytes(row, offset, typ, value)
+func (table *Table) compareLess(row []byte, offset uint16, ct colType, value interface{}) bool {
+	c, ok := table.compareFieldBytes(row, offset, ct, value)
 	return ok && c < 0
 }
 
 // compareGreaterOrEqual 比较字段值是否大于等于给定值
-func (table *Table) compareGreaterOrEqual(row []byte, offset uint16, typ types.FieldType, value interface{}) bool {
-	return table.compareGreater(row, offset, typ, value) || table.compareValue(row, offset, typ, value)
+func (table *Table) compareGreaterOrEqual(row []byte, offset uint16, ct colType, value interface{}) bool {
+	return table.compareGreater(row, offset, ct, value) || table.compareValue(row, offset, ct, value)
 }
 
 // compareLessOrEqual 比较字段值是否小于等于给定值
-func (table *Table) compareLessOrEqual(row []byte, offset uint16, typ types.FieldType, value interface{}) bool {
-	return table.compareLess(row, offset, typ, value) || table.compareValue(row, offset, typ, value)
+func (table *Table) compareLessOrEqual(row []byte, offset uint16, ct colType, value interface{}) bool {
+	return table.compareLess(row, offset, ct, value) || table.compareValue(row, offset, ct, value)
 }
 
 func (table *Table) GetFieldCount() byte       { return table.info.FieldCount }
@@ -831,11 +911,11 @@ func (table *Table) serializeSchema() []byte {
 // writeSchema 整条重写本表的 schema 定义
 //
 // 布局:
-//   1) 内联块 —— 表页内, 紧跟 TableInfo, 固定 FieldAllocateSize 字节, **无跳转槽**。
-//      建表时按 len(serializeSchema()) 精确分配 (上限 MaxInlineSchemaSize)。
-//   2) 续块   —— schema 超出内联块的部分, 写入「全库共享」的 schema 扩展页,
-//      每块格式 [SchemaFragHdr 8B][payload], 多块由 hdr.NextPage/NextOff 串成链。
-//      本表首个续块的位置记录在 TableInfo.ExtPage/ExtOff。
+//  1. 内联块 —— 表页内, 紧跟 TableInfo, 固定 FieldAllocateSize 字节, **无跳转槽**。
+//     建表时按 len(serializeSchema()) 精确分配 (上限 MaxInlineSchemaSize)。
+//  2. 续块   —— schema 超出内联块的部分, 写入「全库共享」的 schema 扩展页,
+//     每块格式 [SchemaFragHdr 8B][payload], 多块由 hdr.NextPage/NextOff 串成链。
+//     本表首个续块的位置记录在 TableInfo.ExtPage/ExtOff。
 //
 // 续块只追加、不单独释放: 每次都是整条重写, 旧续块立刻成为垃圾, 由
 // SchemaExtAllocator 在尾页放不下时做「满页压缩重排」统一回收。因此这里
@@ -955,16 +1035,75 @@ func (table *Table) writeFrags(frags []schemaFrag, rest []byte) error {
 	return nil
 }
 
+// checkProspectiveRowSize 预演"把 newField 加进当前活跃字段集合之后"的行宽。
+//
+// 加列只会让行变宽, 而超宽的行写不进 DataPage (见 serializeRowInternal 的写行闸门),
+// 所以必须在建列这一步就拒绝: 否则这张表从加列成功的那一刻起, 一条也写不进去 ——
+// 而且是插数据时才暴露 (而且加列本身是"成功"的, 没有任何回滚线索)。
+func (table *Table) checkProspectiveRowSize(newField types.FieldInfo) error {
+	sv := table.getVersion(table.activeVersion)
+	probe := make([]types.FieldInfo, 0, len(table.Field)+1)
+	if sv != nil {
+		for _, idx := range sv.FieldIndices {
+			if int(idx) < len(table.Field) {
+				probe = append(probe, table.Field[idx])
+			}
+		}
+	}
+	probe = append(probe, newField)
+	if err := types.CheckRowSize(probe); err != nil {
+		return fmt.Errorf("新增列 %q 后行宽超限: %w", newField.FieldName, err)
+	}
+	return nil
+}
+
 func (table *Table) AddField(newField types.FieldInfo) error {
+	// 列级参数必须先自洽: 宽度码/scale 越界会让该列宽度算成 0, 而 0 宽度会少算字节,
+	// 让其后每一列都从错位偏移解码 (见 types.FieldSize)。
+	if err := validateFieldsForSchema([]types.FieldInfo{newField}); err != nil {
+		return err
+	}
+
+	// 列数闸门与建表同一条 (见 checkFieldCount): master registry 只增不减
+	// (DropField 是软删除, 列仍留在 registry 里), 所以按"加完之后"的列数判断。
+	if err := checkFieldCount(len(table.Field) + 1); err != nil {
+		return err
+	}
+
+	// 版本号闸门必须在【任何改动之前】: 两个分支都会在最后新增一版, 版本耗尽时
+	// 若已经把 in-memory 状态改了一半再返回错误, 表就停在"改了一半"的不一致状态。
+	nextVer, err := table.nextSchemaVersion()
+	if err != nil {
+		return err
+	}
+
 	existing := table.findFieldByName(newField.FieldName)
 	if existing != nil {
 		// 字段存在且活跃 → 报错
 		if !existing.IsDropped() {
 			return ErrFieldExists
 		}
-		// 字段已删除 → 复活它
-		existing.FieldType = newField.FieldType // 恢复类型
-		existing.Flag = newField.Flag           // 列级标志 (scale / 可空) 一并恢复
+		// 复活必须沿用原类型与列级参数。
+		//
+		// master registry 的类型与列级参数是【所有历史版本共用】的行偏移依据
+		// (见 getVersionedOffset / rowDataSizeForVersion)。旧实现是整字节覆盖
+		// (existing.FieldType = newField.FieldType), 看着像"恢复", 实际是"改掉":
+		// varchar(50) 的列复活成 varchar(500) 之后, 已经落盘的旧行仍按 224 字节排布,
+		// 却会被按 2048 字节解析 —— 该列之后的每一个字段都静默错位。
+		// 想改宽度只能换列名新建, 或者重建表 (DropField 本就只标记删除、保留原类型)。
+		if existing.FieldType != newField.FieldType || existing.Param() != newField.Param() {
+			return fmt.Errorf("%w: 列 %q 已被删除, 复活时不允许改变类型/宽度 "+
+				"(原 %s/参数 %d, 新 %s/参数 %d); 请改用新列名, 或重建该表",
+				ErrFieldExists, newField.FieldName,
+				types.TypeNameOf(existing.FieldType), existing.Param(),
+				types.TypeNameOf(newField.FieldType), newField.Param())
+		}
+		// 复活同样会让行变宽, 先做行宽预演
+		if err := table.checkProspectiveRowSize(newField); err != nil {
+			return err
+		}
+		// 只恢复"存活 + 可空/唯一"这些标志位, 低 5 位的列级参数保持原值
+		existing.Flag = (existing.Flag & types.FlagScaleMask) | (newField.Flag &^ types.FlagScaleMask)
 		table.info.ActiveFieldCount++
 
 		// 找到该字段在 master registry 中的索引
@@ -984,9 +1123,9 @@ func (table *Table) AddField(newField types.FieldInfo) error {
 		}
 		newIndices = append(newIndices, byte(revivedIdx))
 
-		table.activeVersion++
+		table.activeVersion = nextVer
 		table.versions = append(table.versions, SchemaVersion{
-			Version:      table.activeVersion,
+			Version:      nextVer,
 			FieldIndices: newIndices,
 		})
 
@@ -998,7 +1137,12 @@ func (table *Table) AddField(newField types.FieldInfo) error {
 		return nil
 	}
 
-	// 全新字段: 追加到 master registry
+	// 全新字段: 同样先做行宽预演 (加列只会让行更宽)
+	if err := table.checkProspectiveRowSize(newField); err != nil {
+		return err
+	}
+
+	// 追加到 master registry
 	table.Field = append(table.Field, newField)
 	table.info.FieldCount++
 	table.info.ActiveFieldCount++
@@ -1011,9 +1155,9 @@ func (table *Table) AddField(newField types.FieldInfo) error {
 	}
 	newIndices = append(newIndices, byte(len(table.Field)-1))
 
-	table.activeVersion++
+	table.activeVersion = nextVer
 	table.versions = append(table.versions, SchemaVersion{
-		Version:      table.activeVersion,
+		Version:      nextVer,
 		FieldIndices: newIndices,
 	})
 
@@ -1064,6 +1208,12 @@ func (table *Table) DropField(fieldName string) error {
 		return ErrFieldNotFound
 	}
 
+	// 版本号闸门必须在改动之前 (理由同 AddField): 删列也要新增一版 schema。
+	nextVer, err := table.nextSchemaVersion()
+	if err != nil {
+		return err
+	}
+
 	// 标记删除 (FieldType 本身不动: 它是"这一列是什么", 与存活状态无关)
 	table.Field[masterIdx].Flag |= types.FlagDeleted
 	table.info.ActiveFieldCount--
@@ -1079,9 +1229,9 @@ func (table *Table) DropField(fieldName string) error {
 		}
 	}
 
-	table.activeVersion++
+	table.activeVersion = nextVer
 	table.versions = append(table.versions, SchemaVersion{
-		Version:      table.activeVersion,
+		Version:      nextVer,
 		FieldIndices: newIndices,
 	})
 
@@ -1095,8 +1245,8 @@ func (table *Table) DropField(fieldName string) error {
 
 // ======================== 动态字段堆页存储 ========================
 //
-// 动态字段 (FieldType_Dynamicbyte: maxlen>256 的字符串 / []byte) 的行内只保存
-// 6 字节引用: [PageID uint32 BE(4B)][Offset uint16 BE(2B)]
+// 动态字段 (FieldType_Dynamicbyte: 声明宽度超过 MaxInlineColumnWidth(2048) 字节的
+// 字符串 / []byte) 的行内只保存 6 字节引用: [PageID uint32 BE(4B)][Offset uint16 BE(2B)]
 //   - PageID == 0 → 空值 (NULL / 空串 / 空二进制)
 //   - 否则指向堆页 PageID 的物理偏移 Offset 处的数据块
 // 堆页数据块格式: [Len uint32 BE][Payload...]
@@ -1117,8 +1267,27 @@ const dynamicBlockHdr = 4 // 堆数据块的长度前缀大小
 // 为什么单独列出来: Blob 是"存文件/大对象"的类型, 使用者必须能在写入前就知道
 // 这条线, 而不是写进去才撞上 heapAllocBlock 里那句与"值太大"看不出因果的
 // ErrHeapPageTooLarge。写入侧的检查见 writeFieldValueAt。
+//
+// 它同时是【声明侧】的容量上限 (声明超限的字符串列也落动态列, 容量同样是这个数):
+// types.MaxStringSize 就是它, 由下面那行编译期断言钉住。
 const MaxBlobSize = PageSize - page.PageHdrSize - dynamicBlockHdr
 
+// 编译期断言: types 里那组【页面布局镜像】推出的两个上限, 必须与这里的物理布局一致
+// (行宽那条因为要整除, 只钉到 ±1 字节 —— "它恰好是一页能放下的最大值"这个精确性质由
+// core 的 TestMaxRowDataSizeFitsTwoRows 用真实的 PageSize / DataPageHdrSize 复核)。
+// 两处一旦漂移, 后果不是报错而是"承诺了做不到的事":
+//   - MaxStringSize 对不上 MaxBlobSize → 允许声明一个写不进堆页的容量, 建表时通过、
+//     某天写入大值时才撞 ErrHeapPageTooLarge;
+//   - MaxRowDataSize 对不上"一页两行" → 放进来一张两行装不进一页的表, 页内顺序扫描、
+//     按 pkID 的有序插入、更新时的页内重建全部退化, 正是那条政策要避免的情形。
+//
+// 用"下标必须为 0"表达相等: 常量下标越界直接编译失败, 且表达式不占任何空间。改页大小 /
+// 页头 / 块长度前缀时这里会先报错, 提醒同步 types 里那组镜像。
+var (
+	_ = [1]struct{}{}[int(MaxBlobSize)-types.MaxStringSize]
+	_ = [1]struct{}{}[int(types.MaxRowDataSize)-int((PageSize-page.DataPageHdrSize-1)/2)]
+)
+
 // maxHeapRefCount 单个堆页的引用数防御上限 (RefCount 为 uint16, 容器上限 65535)。
 //
 // 注意: 这是纯防御性常量, 实际永远不会触发 —— 堆页块数的物理上限只有 3272
@@ -1387,7 +1556,7 @@ func (table *Table) heapReleaseRow(rowData []byte) {
 				}
 			}
 		}
-		offset += getFieldSize(f.FieldType)
+		offset += f.Width()
 	}
 }
 
@@ -1455,10 +1624,15 @@ func lookupFieldValue(values []types.FieldValue, name []byte) (interface{}, bool
 
 // writeFieldValueAt 写入单个字段; 动态字段写入行内 6B 堆引用
 //
+// 形参是 FieldInfo 而不是 FieldType: 宽度与列级参数都必须取自【同一个列定义】——
+// 拆成两个参数传 (类型来自这里、宽度码来自那里) 就会出现"按 256 字节编、按 2048 字节存"
+// 这类不一致, 而它表现为整行错位而不是报错。
+//
 // checkOnly 为真时动态列直接跳过 (行内引用保持全零), 见 serializeRowForCheck: 试算行
 // 只用于算索引键, 绝不能有分配/覆写/释放堆块的副作用 —— 它的结果可能被整丢弃。
 func (table *Table) writeFieldValueAt(rowData []byte, offset uint16, masterIdx int,
-	ft types.FieldType, value interface{}, oldRowData []byte, checkOnly bool) error {
+	f types.FieldInfo, value interface{}, oldRowData []byte, checkOnly bool) error {
+	ft := f.FieldType
 	if types.IsDynamic(ft) {
 		if checkOnly {
 			return nil
@@ -1475,35 +1649,50 @@ func (table *Table) writeFieldValueAt(rowData []byte, offset uint16, masterIdx i
 			}
 			data = b
 		}
-		// Blob 的单块尺寸闸门: 超限在这里就报出列名与上限, 而不是等 heapAllocBlock
+		// 动态列的单块尺寸闸门: 超限在这里就报出列名与上限, 而不是等 heapAllocBlock
 		// 抛出与"值太大"因果关系不明的 ErrHeapPageTooLarge。
-		if ft == types.FieldType_Blob && uint32(len(data)) > MaxBlobSize {
-			return fmt.Errorf("%w: 字段 %q (blob) 的值 %d 字节超过单块上限 %d 字节, "+
-				"更大的对象需要分块存储 (见 types.FieldType_Blob)",
-				types.ErrValueRange, table.Field[masterIdx].FieldName, len(data), MaxBlobSize)
+		//
+		// 必须覆盖【所有】动态列, 不能只拦 Blob: Dynamicbyte 与 Blob 的盘上布局相同
+		// (行内 6B 引用, 内容整块落进一个堆页), 单值上限也一样。只拦 Blob 的话,
+		// 往 Dynamicbyte 写一个超限值只会得到不带列名、不带字节数的
+		// "申请空间超过单页最大容量", 用户看不出是哪一列、超了多少。
+		if uint32(len(data)) > MaxBlobSize {
+			return fmt.Errorf("%w: 字段 %q (%s) 的值 %d 字节超过单块上限 %d 字节, "+
+				"更大的内容需要分块存储 (见 types.FieldType_Blob)",
+				types.ErrValueRange, table.Field[masterIdx].FieldName,
+				types.TypeNameOf(ft), len(data), MaxBlobSize)
 		}
 		return table.writeDynamicField(rowData, offset, masterIdx, data, oldRowData)
 	}
-	size := getFieldSize(ft)
+	size := f.Width()
+	if size == 0 {
+		return fmt.Errorf("%w: 字段 %q (%s) 的列级参数 %d 非法, 宽度不可计算",
+			types.ErrValueRange, f.FieldName, types.TypeNameOf(ft), f.Param())
+	}
 	if int(offset)+int(size) > len(rowData) {
 		return fmt.Errorf("%w: 字段 %d 写入越界 (offset=%d, 列宽=%d, 行长=%d)",
 			types.ErrEncodeBufSize, masterIdx, offset, size, len(rowData))
 	}
 	// 编码统一走 types.EncodeValue —— 与索引键字节同源 (见 types/codec.go)。
 	// param 取列级类型参数: decimal 必须在这里校验"值的 scale == 列声明的 scale",
-	// 否则同一列出现两种 scale 会让"字节序 == 数值序"失效, 索引范围扫描静默漏行。
-	if err := types.EncodeValue(ft, table.fieldScale(masterIdx), value, rowData[offset:int(offset)+int(size)]); err != nil {
+	// 否则同一列出现两种 scale 会让"字节序 == 数值序"失效, 索引范围扫描静默漏行;
+	// 字符串列则由它决定列宽闸门 (超宽在 EncodeValue 里直接报错, 不再截断)。
+	if err := types.EncodeValue(ft, f.Param(), value, rowData[offset:int(offset)+int(size)]); err != nil {
 		return fmt.Errorf("字段 %q 写入失败: %w", table.Field[masterIdx].FieldName, err)
 	}
 	return nil
 }
 
-// fieldScale 取列级 scale (FieldType_Decimal 的小数位数, 其余类型为 0)。
-func (table *Table) fieldScale(masterIdx int) byte {
+// fieldParam 取列级类型参数 (Flag 低 5 位): decimal 是 scale, 字符串家族是行内宽度码,
+// 其余类型为 0 (由 types.ValidateColumnParam 保证)。
+//
+// 以前这里叫 fieldScale 且只服务 decimal; 字符串列也用上同一格之后, "取参数"与
+// "算宽度"必须成对 —— 只传 scale 会漏掉宽度码, 编出来的字节宽度与行内不一致。
+func (table *Table) fieldParam(masterIdx int) byte {
 	if masterIdx < 0 || masterIdx >= len(table.Field) {
 		return 0
 	}
-	return table.Field[masterIdx].Flag & types.FlagScaleMask
+	return table.Field[masterIdx].Param()
 }
 
 // serializeRow 将 FieldValue 切片序列化为行数据字节 (不含 pkID 前缀)
@@ -1534,7 +1723,24 @@ func (table *Table) serializeRowForCheck(values []types.FieldValue, oldRowData [
 
 func (table *Table) serializeRowInternal(values []types.FieldValue, oldRowData []byte, checkOnly bool) ([]byte, error) {
 	ver := table.pickVersionForUpdate(oldRowData, values)
-	rowData := make([]byte, table.rowDataSizeForVersion(ver))
+
+	// 写行闸门 (第三处行宽校验)。
+	//
+	// 前两处在建列侧 (TablePage.Create / Table.AddField), 管的是"新表不许超宽";
+	// 这一处管的是"已经存在的老表": 表是在限制生效前建的 (或 schema 被外部改坏) 时,
+	// 行字节数会超过一个 DataPage 能装下的量。放任它走下去的后果不是"写失败"这么轻——
+	// 行长度会在 DataPage 里溢出到相邻区域, 把整页的行解析全部错位 (页内行是顺序排布、
+	// 靠每行 4B 长度前缀自描述的, 一行错位等于整页报废)。
+	//
+	// 放在这里而不是"逐字段校验"是因为行总长只有这里算得出来 (行宽由 schema 版本决定)。
+	// 代价是每行一次整数比较。
+	rowSize := table.rowDataSizeForVersion(ver)
+	if rowSize > MaxPhysicalRowData {
+		return nil, fmt.Errorf("%w: 表 %q 的 schema 版本 %d 行宽 %d 字节超过单页可容纳上限 %d 字节, 拒绝写入; "+
+			"请重建该表并把大列改为动态列 (FieldType_Dynamicbyte / Blob)",
+			types.ErrValueRange, util.BytesToString(table.info.Name[:]), ver, rowSize, MaxPhysicalRowData)
+	}
+	rowData := make([]byte, rowSize)
 	rowData[0] = ver
 
 	sv := table.getVersion(ver)
@@ -1556,7 +1762,6 @@ func (table *Table) serializeRowInternal(values []types.FieldValue, oldRowData [
 	offset := uint16(1)
 	for i, masterIdx := range sv.FieldIndices {
 		f := table.Field[masterIdx]
-		ft := f.FieldType
 		name := f.FieldName
 		// 取值优先级: 本次传入 > 旧行原值 (未提供的字段沿用旧值, 否则"只改一列"会清空其它列)
 		value, provided := lookupFieldValue(values, name)
@@ -1564,25 +1769,34 @@ func (table *Table) serializeRowInternal(values []types.FieldValue, oldRowData [
 			value, provided = lookupFieldValue(base, name)
 		}
 		if provided && value == nil {
-			if f.IsNullable() {
-				// NULL: 位图置位, 字段区留零 (行长固定; 动态列因此不会写入堆引用)。
-				// 试算模式也要置位 —— extractIndexKey 正是靠位图判定"该行不进本索引"。
-				types.SetNullBit(bitmap, i)
-				// 旧行若在该列持有堆块, 新行不再引用它 —— 不在这里释放就是每更新一次漏一块。
-				if !checkOnly {
-					table.releaseOldDynamic(oldRowData, int(masterIdx))
-				}
-			} else if err := table.writeFieldValueAt(rowData, offset, int(masterIdx), ft, value, oldRowData, checkOnly); err != nil {
-				// 未声明 nullable 的列: 保持既有"nil 按零值写"的行为 (收紧成报错会让
-				// 所有"传 nil 当零值"的既有调用当场失败), 由声明侧决定是否可空。
-				return nil, err
+			if !f.IsNullable() {
+				// 非空列写 NULL: 拒绝并点名列。
+				//
+				// 这里不是"收紧", 而是把已有的不一致统一掉: 定长列写 nil 本来就走不通
+				// (EncodeValue 对 nil 报 ErrValueType), 唯独动态列曾把 nil 静默当成
+				// "空内容"存下去 (行内引用全 0, NULL 位图也不置位) —— 同一句"写 nil"
+				// 在定长列报错、在动态列静默成功。既然声明了非空, 就该两边都拒。
+				//
+				// 注意 base (旧行原值) 不会贡献 nil: deserializeRow 只 append 非 nil 的值
+				// (见那里的 `if val != nil`), 所以这条只拦"本次显式传入的 NULL",
+				// 不会让"只改其它列"的更新半路失败。
+				return nil, fmt.Errorf("%w: 字段 %q (%s) 未声明可空 (nullable), 不接受 NULL; "+
+					"要允许写入 NULL, 请在列声明里加 `nullable` / `null`",
+					types.ErrValueRange, name, types.TypeNameOf(f.FieldType))
+			}
+			// NULL: 位图置位, 字段区留零 (行长固定; 动态列因此不会写入堆引用)。
+			// 试算模式也要置位 —— extractIndexKey 正是靠位图判定"该行不进本索引"。
+			types.SetNullBit(bitmap, i)
+			// 旧行若在该列持有堆块, 新行不再引用它 —— 不在这里释放就是每更新一次漏一块。
+			if !checkOnly {
+				table.releaseOldDynamic(oldRowData, int(masterIdx))
 			}
 		} else if provided {
-			if err := table.writeFieldValueAt(rowData, offset, int(masterIdx), ft, value, oldRowData, checkOnly); err != nil {
+			if err := table.writeFieldValueAt(rowData, offset, int(masterIdx), f, value, oldRowData, checkOnly); err != nil {
 				return nil, err
 			}
 		}
-		offset += getFieldSize(ft)
+		offset += f.Width()
 	}
 	return rowData, nil
 }
@@ -1647,7 +1861,7 @@ func (table *Table) deserializeRow(rowData []byte) []types.FieldValue {
 		// NULL 不出现在结果里 —— 与"零值不 append"是同一约定: 读不到值就是没有值;
 		// 更新路径靠它区分"沿用旧值"与"显式写 NULL"(见 serializeRow 的取值优先级)。
 		if !f.IsDropped() && !types.NullBitSet(bitmap, i) {
-			size := getFieldSize(f.FieldType)
+			size := f.Width()
 			if int(offset)+int(size) <= len(rowData) {
 				var val interface{}
 				if types.IsDynamic(f.FieldType) {
@@ -1665,7 +1879,7 @@ func (table *Table) deserializeRow(rowData []byte) []types.FieldValue {
 				}
 			}
 		}
-		offset += getFieldSize(table.Field[idx].FieldType)
+		offset += table.Field[idx].Width()
 	}
 	return result
 }

+ 32 - 5
DB/core/TablePage.go

@@ -7,6 +7,7 @@ import (
 	"TsDB2/DB/core/types"
 	"TsDB2/DB/core/util"
 	"encoding/binary"
+	"fmt"
 	"unsafe"
 )
 
@@ -292,23 +293,49 @@ func (t *TablePage) appendPage() (uint32, error) {
 	return pid, nil
 }
 
+// checkFieldCount 校验列数能否落盘 (建表与加列共用同一条闸门)。
+//
+// 列数上限见 MaxFieldsPerTable: FieldCount 与字段索引都是 1 字节, 超了会回绕 / 截断。
+// 错误里带上"本次多少列、上限多少列", 只说"字段数量非法"用户不知道要砍到几列。
+func checkFieldCount(n int) error {
+	if n <= 0 {
+		return fmt.Errorf("%w: 一张表至少要有 1 列", ErrInvalidFieldCount)
+	}
+	if n > MaxFieldsPerTable {
+		return fmt.Errorf("%w: 一张表最多 %d 列, 本次 %d 列 "+
+			"(列数与字段索引都是 1 字节, 超过会截断成错位 schema)",
+			ErrInvalidFieldCount, MaxFieldsPerTable, n)
+	}
+	return nil
+}
+
 // ===========================================================================
 // ✅ Create 完整正确(字段数量全是 byte,255上限)
 // ===========================================================================
 func (t *TablePage) Create(name string, fields []types.FieldInfo) (*Table, error) {
-	fieldCount := byte(len(fields))
-
 	if len(name) > TableNameSize {
-		return nil, ErrTableNameTooLong
+		return nil, fmt.Errorf("%w: 表名 %q 有 %d 字节, 上限 %d",
+			ErrTableNameTooLong, name, len(name), TableNameSize)
 	}
 	for _, tb := range t.tables {
 		if util.BytesToString(tb.info.Name[:]) == name && !util.IsHighBitSet(tb.info.Flag) {
 			return nil, ErrTableExists
 		}
 	}
-	if fieldCount == 0 || int(fieldCount)+1 >= MaxFieldSize {
-		return nil, ErrInvalidFieldCount
+	if err := checkFieldCount(len(fields)); err != nil {
+		return nil, err
 	}
+	// 建表即校验列级参数与行宽。
+	//
+	// 行宽是 schema 属性而不是行数据属性: 一张建出来就超上限的表, 之后每一行都会写入
+	// 失败, 而且是在插数据时才暴露。必须在建表这一步拒绝, 并把"哪几列占了多少"报出来。
+	// 校验放在 core 而不是只留在 ORM/migrate: schema 也能从 SQL 或低层 API 直接建。
+	if err := validateFieldsForSchema(fields); err != nil {
+		return nil, err
+	}
+
+	// 过完上面两道闸门后 len(fields) ≤ MaxFieldsPerTable(254), 转 byte 不会回绕。
+	fieldCount := byte(len(fields))
 
 	// 内联块按 schema 实际序列化长度精确分配 (不再 ×2 预分配, 也不加 256B 下限)。
 	// 超出 MaxInlineSchemaSize 的部分截断, 由 writeSchema 写进共享扩展页。

+ 1 - 1
DB/core/condition.go

@@ -11,7 +11,7 @@ type Condition interface {
 // 注意: 下面示例里 Eq/Gt/Lt/Ge/Le/In 的第三个参数一律写成 types.FieldType(0) ——
 // 该参数已废弃 (Go 无法标记单个参数废弃, 所以各构造函数都没写 "Deprecated:" 段落,
 // 否则会连函数本身一起被判为废弃): 比较一律按列的实际存储类型进行
-// (见 Table.compareTypeFor), 传什么值结果都一样。
+// (见 Table.compareColTypeFor), 传什么值结果都一样。
 //
 // 注意区分: Insert().Set("id", uint64(1), types.FieldType_8byte) 的类型参数是必需的
 // —— 写入要靠它决定编码方式, 传 0 会直接报错。废弃的只有【条件构造函数】的第三个

+ 34 - 2
DB/core/const.go

@@ -1,4 +1,4 @@
-package core
+package core
 
 import (
 	"time"
@@ -72,8 +72,40 @@ const (
 	// 放进共享 schema 扩展页 (避免单表吃满整张表页)
 	MaxInlineSchemaSize = 4096
 
-	// MaxFieldSize 单次创建字段最大数量
+	// MaxFieldSize 单次创建字段的最大数量 (建表那一批次的字段个数上界)。
 	MaxFieldSize = 256
+
+	// MaxFieldsPerTable 一张表的列数上限 (master registry, 含已被 DropField 软删除的列)。
+	//
+	// 两条约束取更紧的一条:
+	//   - 硬约束: TableInfo.FieldCount 与 schema 里的字段索引 (SchemaVersion.FieldIndices)
+	//     都是 1 字节, 列数到 256 就会回绕 / 截断 → 绝对上限 255;
+	//   - 建表政策: 在 255 上再留 1 格余量 → MaxFieldSize-2 = 254。
+	//
+	// 建表 (TablePage.Create) 与加列 (Table.AddField) 必须卡同一条: 只在建表卡住而加列
+	// 不限, 就能一路推到 255 以上, 落盘时把 FieldCount / 字段索引截断成错位 schema ——
+	// 写进去的时候不报错, 读回来整表错乱。
+	//
+	// 导出给库外的调用方 (migrate 要在读源库结构时就提示"源表有多少列、VRTX 只到多少列",
+	// 而不是等建表时抛一句"字段数量非法"): 这是 VRTX 的能力边界, 不该只藏在 core 里。
+	MaxFieldsPerTable = MaxFieldSize - 2
+
+	// FieldNameSize 列名的最大字节数。
+	//
+	// 列名在 schema 里不是定长数组, 而是 [nameLen(1B)][name...]: 长度前缀只有 1 字节,
+	// 因此上限是 255。超长会让落盘的 nameLen 被截断, 解析侧从错的偏移读后续字段 ——
+	// 不是"名字被缩短"这么轻, 是整张表错位, 所以建列时必须拒绝
+	// (见 validateFieldsForSchema)。
+	FieldNameSize = 255
+
+	// MaxIndexFields 一个索引最多允许的分量列数。
+	//
+	// 上限取 16: IndexMeta.FieldIDs 是定长 [16]byte, 超出的分量根本无处落盘
+	// (会被静默丢弃, 于是索引只覆盖了前 16 列却声称覆盖全部)。见 IndexMetaPage.CreateIndex。
+	//
+	// 导出给库外的调用方: 迁移侧要能提示"源库这个索引有几列、VRTX 只到多少列"
+	// (SQLite 的索引列数几乎没有限制, 直接建会撞上 core 的闸门)。
+	MaxIndexFields = 16
 )
 
 // =============================================================================

+ 91 - 33
DB/core/db.go

@@ -36,9 +36,17 @@ type DB struct {
 	autoCommitTimer *time.Timer
 	autoCommitMu    sync.Mutex
 
+	// commitMu 提交互斥: 防抖定时器 / 脏页阈值回调 / 用户显式提交 / 事务提交共用。
+	// 只串行化"提交"这一步, 不参与写入路径 —— 详见 DB.Commit 的说明。
+	commitMu sync.Mutex
+
 	// 当前活跃事务(nil 表示无事务)
 	currentTxn *Transaction
 
+	// pendingMu 保护 pendingFree / pendingFreeChain: 写入路径登记、提交路径出队,
+	// 两条路径并发 (写线程插入/删除 + 后台提交线程) 时必须串行化, 否则切片并发读写
+	// 会丢登记项 / 触发数据竞争。
+	pendingMu sync.Mutex
 	// pendingFree 无事务上下文时积压的"待回收页"(堆页摘链 / 批量删除后作废的旧 DataPage)。
 	// 不在修改中途直写盘, 而是等 Commit 成功 / Close 后再真正还链 —— 见 DeferFreePage 的说明。
 	// currentTxn != nil 时改入 txn.pendingFree (Rollback 时整体丢弃)。
@@ -103,11 +111,13 @@ func Open(path string) (*DB, error) {
 	// "脏"的旧内容, 不覆盖的话 Commit 收集脏页时会把刚写好的空闲链表指针反向
 	// 覆盖掉, 导致空闲链错乱/页被重复分配。
 	realFreePage := func(pageID uint32) {
-		header.recyclePage(pageID, ff.WritePageDirect)
-		syncBuf := make([]byte, PageSize)
-		_ = ff.readPageDisk(pageID, syncBuf)
-		ff.SyncCache(pageID, syncBuf)
-		_ = ff.WriteHeader(header)
+		ff.withHeader(func() {
+			header.recyclePage(pageID, ff.WritePageDirect)
+			syncBuf := make([]byte, PageSize)
+			_ = ff.readPageDisk(pageID, syncBuf)
+			ff.SyncCache(pageID, syncBuf)
+			_ = ff.writeHeaderLocked(header)
+		})
 	}
 
 	// realFreeChain 把一条已用 PageHdr.NextPage 串好的整链一次性还链 (O(1))。
@@ -116,10 +126,15 @@ func Open(path string) (*DB, error) {
 	// 代价与链长无关。逐页遍历回收 N 页需要 2N 次 IO (每页一次读 + 一次写),
 	// 拼接只要 3 次 —— DropTable 的堆链、WAL 提交后的 WAL 链都走这里。
 	realFreeChain := func(head, tail, count uint32) error {
-		if err := recycleChainWithCache(ff, header, head, tail, count); err != nil {
-			return err
-		}
-		return ff.WriteHeader(header)
+		var err error
+		ff.withHeader(func() {
+			err = recycleChainWithCache(ff, header, head, tail, count)
+			if err != nil {
+				return
+			}
+			err = ff.writeHeaderLocked(header)
+		})
+		return err
 	}
 
 	// 闭包在调用时才解引用 db, 因此可以先声明、稍后再赋值
@@ -128,15 +143,15 @@ func Open(path string) (*DB, error) {
 	tablePage := &TablePage{
 		handle: page.HandleDb{
 			NewPage: func() uint32 {
-				// 读空闲链必须直接读盘 (绕过 BufferPool); 擦页交给 WritePage —— 调用方
-				// 紧接着的整页写入会覆盖池里同一份缓存, 因此不额外产生物理 IO。
-				pid := header.newPageWithReader(ff.readPageDisk, ff.WritePage)
+				// allocPage(erase=true): headerMu 内摘空闲链 / 递增游标并推进头页; 擦页在
+				// 锁外走 WritePage —— 调用方紧接着的整页写入会覆盖池里同一份缓存, 因此不
+				// 额外产生物理 IO。读空闲链直接读盘 (绕过 BufferPool)。
+				pid := ff.allocPage(header, true)
 				// 事务内新分配的页登记到事务: 分配已就地推进 header 游离指针并写盘(不可回退),
 				// Rollback 时必须把这些页还给空闲链, 否则成为永久泄漏的孤儿页。
 				if db != nil && db.currentTxn != nil {
 					db.currentTxn.allocated = append(db.currentTxn.allocated, pid)
 				}
-				_ = ff.WriteHeader(header)
 				return pid
 			},
 			FreePage: func(pageID uint32) {
@@ -159,7 +174,9 @@ func Open(path string) (*DB, error) {
 					db.currentTxn.pendingFree = append(db.currentTxn.pendingFree, pageID)
 					return
 				}
+				db.pendingMu.Lock()
 				db.pendingFree = append(db.pendingFree, pageID)
+				db.pendingMu.Unlock()
 			},
 			ReadPage: ff.ReadPage,
 			SavePage: ff.WritePage,
@@ -182,14 +199,19 @@ func Open(path string) (*DB, error) {
 					db.currentTxn.pendingFreeChain = append(db.currentTxn.pendingFreeChain, c)
 					return
 				}
+				db.pendingMu.Lock()
 				db.pendingFreeChain = append(db.pendingFreeChain, c)
+				db.pendingMu.Unlock()
 			},
 			// ValidPage: 页号合法性 = 落在已分配区间 [2, NextFreePage)。
 			// 0 是头页、1 是建库预留的表页; >= NextFreePage 的页从未分配过, 文件里没有
 			// 它的位置 —— 放进空闲链, 下一次 NewPage 就会拿着它写到几十 GB 偏移处
 			// (实测 DROP TABLE 假死 52s、稀疏文件暴涨到 40GB 的直接原因)。
 			ValidPage: func(pageID uint32) bool {
-				return pageID >= 2 && pageID < header.NextFreePage
+				ff.headerMu.Lock()
+				limit := header.NextFreePage
+				ff.headerMu.Unlock()
+				return pageID >= 2 && pageID < limit
 			},
 		},
 	}
@@ -203,7 +225,7 @@ func Open(path string) (*DB, error) {
 	if header.TablePage == 0 {
 		// 用带读盘能力的分配: 即便此时空闲链非空 (例如 TablePage 丢失的旧库),
 		// 也能正确从空闲链摘页; 新库空闲链为空, 走 NextFreePage 递增分配。
-		header.TablePage = header.newPageWithReader(ff.readPageDisk, ff.WritePage)
+		header.TablePage = ff.allocPage(header, true)
 		tablePage.InitHeader(header.TablePage)
 		saveHeader = true
 	}
@@ -248,8 +270,10 @@ func Open(path string) (*DB, error) {
 }
 
 func (db *DB) updateTime() {
-	db.header.ModifyTime = time.Now().UnixNano()
-	_ = db.file.WriteHeader(db.header)
+	db.file.withHeader(func() {
+		db.header.ModifyTime = time.Now().UnixNano()
+		_ = db.file.writeHeaderLocked(db.header)
+	})
 }
 
 func (db *DB) FindTable(name string) (*Table, bool) {
@@ -266,8 +290,10 @@ func (db *DB) CreateTable(name string, fields []types.FieldInfo) (*Table, error)
 		return nil, err
 	}
 	tbl.SetOnWrite(db.NotifyWrite)
-	db.header.TableCount++
-	_ = db.file.WriteHeader(db.header)
+	db.file.withHeader(func() {
+		db.header.TableCount++
+		_ = db.file.writeHeaderLocked(db.header)
+	})
 	db.updateTime()
 	return tbl, nil
 }
@@ -281,8 +307,10 @@ func (db *DB) DropTable(name string) error {
 	if err := db.tables.Drop(name); err != nil {
 		return err
 	}
-	db.header.TableCount--
-	_ = db.file.WriteHeader(db.header)
+	db.file.withHeader(func() {
+		db.header.TableCount--
+		_ = db.file.writeHeaderLocked(db.header)
+	})
 	db.updateTime()
 	// 先刷盘再清缓存, 防止 Drop 中标记的 Flag(0x80) 丢失
 	db.file.FlushBufferPool()
@@ -346,20 +374,34 @@ func (db *DB) freeChainNow(chains []pendingChain) {
 // drainPendingFree 回收无事务上下文时积压的页与整链。
 // 只能在"链指针已经持久化"的时刻调用 —— 即提交成功之后, 或 Close 收尾时。
 func (db *DB) drainPendingFree() {
-	if len(db.pendingFree) == 0 && len(db.pendingFreeChain) == 0 {
-		return
-	}
+	// 先原子地摘下当前队列 (写入路径可能正在并发登记, 只取走此刻已入队的部分),
+	// 再在锁外真正还链 —— 还链会走 headerMu + 磁盘 IO, 不该占着 pendingMu。
+	db.pendingMu.Lock()
 	ids := db.pendingFree
 	db.pendingFree = nil
-	db.freeNow(ids)
 	chains := db.pendingFreeChain
 	db.pendingFreeChain = nil
+	db.pendingMu.Unlock()
+
+	if len(ids) == 0 && len(chains) == 0 {
+		return
+	}
+	db.freeNow(ids)
 	db.freeChainNow(chains)
 }
 
 // Commit 显式提交 — 通过 WAL 保证原子性
 // 同时重置防抖定时器
+//
+// commitMu 保证同一时刻只有一笔提交在飞: 防抖定时器、脏页阈值回调(在写入 goroutine
+// 内同步触发) 和用户显式调用都可能撞在一起, 而两笔提交同时收集/回写同一批脏页会把
+// 页的归属搞乱 (前一笔的快照会把后一笔刚改出来的内容判成"已落盘")。
+// 这把锁只串行化提交本身, 不参与写入路径: 写入只在 BufferPool.WritePage 里等
+// writeLock (见 LockWrites), 两者没有嵌套获取, 因此不会互相死锁。
 func (db *DB) Commit() error {
+	db.commitMu.Lock()
+	defer db.commitMu.Unlock()
+
 	// 取消待执行的自动提交
 	db.autoCommitMu.Lock()
 	if db.autoCommitTimer != nil {
@@ -384,17 +426,28 @@ func (db *DB) Commit() error {
 	}
 
 	// 4. HeaderPage 也纳入 (pageID=0)
-	wal.RecordPage(0, marshalHeader(db.header))
+	//    marshalHeader 要读整张头页 (含提交路径正在改的 WAL 链指针), 必须在 headerMu 内拍快照。
+	db.file.withHeader(func() {
+		wal.RecordPage(0, marshalHeader(db.header))
+	})
 
 	// 5. 原子提交
 	if err := wal.Commit(); err != nil {
-		// 提交失败: 脏页已被 CollectDirty 移出 BufferPool, 必须放回内存,
-		// 否则这部分修改既不落盘也不在缓存, 会被静默丢弃
+		// 提交失败: 这批页必须继续留在缓存里当脏页。CollectDirty 现在不再把它们移出
+		// 缓存, 所以正常情况下它们本来就在; RestoreDirty 只是兜底 (错误路径上页回收
+		// 等动作也可能碰过这些页), 而且只补缺失条目, 不会用旧快照盖掉更新的内容。
 		db.file.RestoreDirty(dirty)
 		return err
 	}
 
-	// 6. 提交成功 → 链指针已持久化, 积压的待回收页现在可以真正还链
+	// 6. 提交成功 → 把这一批页收成干净页。
+	//    必须按快照的版本号逐页判定: 提交窗口里写入线程可能已经把某些页又改了一遍
+	//    (缓存里是更新的内容, 本次 WAL 记的却是快照那份), 那些页要继续留脏, 否则新
+	//    内容既不会被写盘, 脏标记还报告"已持久化" —— 该页一旦被淘汰就永久丢失。
+	//    (旧实现这里是 ClearAll, 无差别地把整个缓存标干净, 恰好会抹掉这种页。)
+	db.file.MarkClean(dirty)
+
+	// 7. 提交成功 → 链指针已持久化, 积压的待回收页现在可以真正还链
 	db.drainPendingFree()
 	return nil
 }
@@ -495,9 +548,11 @@ func marshalHeader(h *HeaderPage) []byte {
 
 // DebugDBState 打印数据库调试信息
 func (db *DB) DebugDBState() {
-	fmt.Printf("[DEBUG] Header: TablePage=%d NextFreePage=%d FreePageStart=%d FirstWAL=%d LastWAL=%d NextTxn=%d\n",
-		db.header.TablePage, db.header.NextFreePage, db.header.FreePageStart,
-		db.header.FirstWALPage, db.header.LastWALPage, db.header.NextTxnID)
+	db.file.withHeader(func() {
+		fmt.Printf("[DEBUG] Header: TablePage=%d NextFreePage=%d FreePageStart=%d FirstWAL=%d LastWAL=%d NextTxn=%d\n",
+			db.header.TablePage, db.header.NextFreePage, db.header.FreePageStart,
+			db.header.FirstWALPage, db.header.LastWALPage, db.header.NextTxnID)
+	})
 	fmt.Printf("[DEBUG] BufferPool: total=%d dirty=%d\n", db.file.bp.Len(), db.file.bp.DirtyCount())
 }
 
@@ -559,5 +614,8 @@ func (db *DB) DebugTableState(tableName string) {
 
 // DebugFreePageCount 返回空闲页数量 (调试用)
 func (db *DB) DebugFreePageCount() int {
-	return int(db.header.FreePageCount)
+	db.file.headerMu.Lock()
+	n := db.header.FreePageCount
+	db.file.headerMu.Unlock()
+	return int(n)
 }

+ 269 - 0
DB/core/limits_test.go

@@ -0,0 +1,269 @@
+package core
+
+import (
+	"errors"
+	"strconv"
+	"strings"
+	"testing"
+
+	"TsDB2/DB/core/page"
+	"TsDB2/DB/core/types"
+)
+
+// 本文件集中钉死"每一处限制在触发时都必须有报错或提示"。
+//
+// 这些闸门原先有的缺口是【静默】的 —— 越界不报错, 而是把 1 字节的 FieldCount /
+// 字段索引 / schemaVer 写回绕掉, 读回来整表错位。所以每个用例除了"必须报错",
+// 还检查"报错里要有用户能据此改代码的信息"(上限与实际值 / 列名)。
+
+// TestFieldCountGate 列数上限: 建表与加列必须卡同一条。
+//
+// 只在 TablePage.Create 卡住而 AddField 不限, 就能反复加列把 master registry 推过
+// 255, 落盘时把 FieldCount 与字段索引 (都是 1 字节) 截断成错位 schema。
+func TestFieldCountGate(t *testing.T) {
+	if err := checkFieldCount(0); err == nil || !errors.Is(err, ErrInvalidFieldCount) {
+		t.Fatalf("0 列应报 ErrInvalidFieldCount, 得到 %v", err)
+	}
+	if err := checkFieldCount(MaxFieldsPerTable); err != nil {
+		t.Fatalf("%d 列应当放行: %v", MaxFieldsPerTable, err)
+	}
+	err := checkFieldCount(MaxFieldsPerTable + 1)
+	if err == nil || !errors.Is(err, ErrInvalidFieldCount) {
+		t.Fatalf("%d 列应报 ErrInvalidFieldCount, 得到 %v", MaxFieldsPerTable+1, err)
+	}
+	// 只说"字段数量非法"没法用: 用户要知道上限是多少、本次是多少
+	for _, want := range []string{strconv.Itoa(MaxFieldsPerTable), strconv.Itoa(MaxFieldsPerTable + 1)} {
+		if !strings.Contains(err.Error(), want) {
+			t.Errorf("列数超限的报错缺少 %q: %v", want, err)
+		}
+	}
+
+	// 加列路径也必须走同一条闸门
+	db := fixOpenDB(t)
+	fields := make([]types.FieldInfo, MaxFieldsPerTable)
+	for i := range fields {
+		fields[i] = types.FieldInfo{
+			FieldType: types.FieldType_1byte,
+			FieldName: []byte("c" + strconv.Itoa(i)),
+		}
+	}
+	if _, err := db.CreateTable("FULL", fields); err != nil {
+		t.Fatalf("建一张 %d 列的表应当成功: %v", MaxFieldsPerTable, err)
+	}
+	tbl, ok := db.FindTable("FULL")
+	if !ok {
+		t.Fatal("找不到 FULL")
+	}
+	err = tbl.AddField(types.FieldInfo{
+		FieldType: types.FieldType_1byte, FieldName: []byte("oneTooMany"),
+	})
+	if err == nil || !errors.Is(err, ErrInvalidFieldCount) {
+		t.Fatalf("超过 %d 列后 AddField 应报 ErrInvalidFieldCount, 得到 %v", MaxFieldsPerTable, err)
+	}
+	if _, present := tbl.GetActiveFieldType("oneTooMany"); present {
+		t.Error("被拒的列不能进 schema")
+	}
+}
+
+// TestSchemaVersionGate schema 版本号是每行行首 1 字节, 版本条数也是 1 字节: 到上限再演进
+// 会回绕成 0, 而 0 不是任何已知版本 → getVersion 返回 nil, 该行 NULL 位图取不到、所有列
+// 静默读成 0/NULL。必须在演进之前拒绝, 而不是"写进去再读不出来"。
+func TestSchemaVersionGate(t *testing.T) {
+	db := fixOpenDB(t)
+	if _, err := db.CreateTable("VER", []types.FieldInfo{
+		{FieldType: types.FieldType_1byte, FieldName: []byte("A")},
+	}); err != nil {
+		t.Fatalf("create: %v", err)
+	}
+	tbl, ok := db.FindTable("VER")
+	if !ok {
+		t.Fatal("找不到 VER")
+	}
+
+	realVer := tbl.activeVersion
+	tbl.activeVersion = 0xFF // 推到"下一版就会回绕"的状态
+	if _, err := tbl.nextSchemaVersion(); err == nil {
+		t.Error("activeVersion=255 时 nextSchemaVersion 应当报错")
+	}
+
+	addErr := tbl.AddField(types.FieldInfo{
+		FieldType: types.FieldType_1byte, FieldName: []byte("B"),
+	})
+	dropErr := tbl.DropField("A")
+	if addErr == nil {
+		t.Error("版本耗尽后 AddField 应当被拒 (否则版本号回绕成 0)")
+	}
+	if dropErr == nil {
+		t.Error("版本耗尽后 DropField 应当被拒 (删列同样新增一版)")
+	}
+
+	// 被拒的调用不能改动任何 in-memory 状态, 否则表会停在"改了一半"
+	if len(tbl.Field) != 1 {
+		t.Errorf("被拒的 AddField 改动了 master registry: %d 列", len(tbl.Field))
+	}
+	if tbl.Field[0].IsDropped() {
+		t.Error("被拒的 DropField 把列标记成了已删除")
+	}
+
+	// 恢复真实版本后, 表必须仍然可用 (闸门不能把表弄坏)
+	tbl.activeVersion = realVer
+	if _, err := tbl.InsertRow([]types.FieldValue{
+		{FieldName: []byte("A"), Value: uint8(1)},
+	}); err != nil {
+		t.Errorf("版本闸门拒绝后表应当仍可写: %v", err)
+	}
+}
+
+// TestFieldNameLengthGate 列名在 schema 里是 [nameLen(1B)][name...]:
+// 空名会让解析侧把下一个字段的类型字节当名字, 超过 255 字节会让 nameLen 截断并让其后
+// 所有字段整体错位。两者都必须在建列时报错。
+func TestFieldNameLengthGate(t *testing.T) {
+	db := fixOpenDB(t)
+
+	_, err := db.CreateTable("EMPTYNAME", []types.FieldInfo{
+		{FieldType: types.FieldType_1byte, FieldName: nil},
+	})
+	if err == nil {
+		t.Error("长度为 0 的列名必须被拒")
+	}
+
+	tooLong := make([]byte, FieldNameSize+1)
+	for i := range tooLong {
+		tooLong[i] = 'a'
+	}
+	_, err = db.CreateTable("LONGNAME", []types.FieldInfo{
+		{FieldType: types.FieldType_1byte, FieldName: tooLong},
+	})
+	if err == nil {
+		t.Fatalf("列名超过 %d 字节必须被拒", FieldNameSize)
+	}
+	// 报错要能直接改代码: 既要有上限, 也要有用户写下的那个长度
+	for _, want := range []string{strconv.Itoa(FieldNameSize), strconv.Itoa(FieldNameSize + 1)} {
+		if !strings.Contains(err.Error(), want) {
+			t.Errorf("列名超长的报错缺少 %q: %v", want, err)
+		}
+	}
+}
+
+// TestIndexFieldCountGate 索引分量个数上限。
+//
+// 旧实现用 `byte(len(fieldIDs))` 判断, 257 个分量会回绕成 1 而"通过", 于是越界的调用
+// 反而建成一个只覆盖 1 列却声称覆盖全部的索引。所以先按 int 判, 再转 byte。
+func TestIndexFieldCountGate(t *testing.T) {
+	db := fixOpenDB(t)
+	if _, err := db.CreateTable("IDXCNT", []types.FieldInfo{
+		{FieldType: types.FieldType_8byte, FieldName: []byte("A")},
+	}); err != nil {
+		t.Fatalf("create: %v", err)
+	}
+	tbl, ok := db.FindTable("IDXCNT")
+	if !ok {
+		t.Fatal("找不到 IDXCNT")
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("create pk: %v", err)
+	}
+
+	if _, err := tbl.CreateIndex("none", page.IndexTypeBTree, nil); err == nil {
+		t.Error("0 个分量必须被拒")
+	}
+	if _, err := tbl.CreateIndex("many", page.IndexTypeBTree, make([]byte, MaxIndexFields+1)); err == nil {
+		t.Errorf("%d 个分量必须被拒", MaxIndexFields+1)
+	}
+	// 257 个分量: 走 byte 会回绕成 1, 必须在这条闸门被挡住
+	if _, err := tbl.CreateIndex("overflow", page.IndexTypeBTree, make([]byte, 257)); err == nil {
+		t.Error("257 个分量 (byte 回绕) 必须被拒")
+	}
+}
+
+// TestDynamicValueSizeGate 动态列 (Dynamicbyte 与 Blob) 的单值上限。
+//
+// 两者盘上布局相同 (行内 6B 引用, 内容整块落进一个堆页), 上限也一样。只拦 Blob 的话,
+// 往 Dynamicbyte 写超限值只会得到底层那句不带列名、不带字节数的 ErrHeapPageTooLarge。
+func TestDynamicValueSizeGate(t *testing.T) {
+	db := fixOpenDB(t)
+	if _, err := db.CreateTable("DYN", []types.FieldInfo{
+		{FieldType: types.FieldType_8byte, FieldName: []byte("I")},
+		{FieldType: types.FieldType_Dynamicbyte, FieldName: []byte("D")},
+		{FieldType: types.FieldType_Blob, FieldName: []byte("B")},
+	}); err != nil {
+		t.Fatalf("create: %v", err)
+	}
+	tbl, ok := db.FindTable("DYN")
+	if !ok {
+		t.Fatal("找不到 DYN")
+	}
+	// 主键要落在定长列 I 上: 动态列不能作为索引键
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("create pk: %v", err)
+	}
+
+	huge := make([]byte, MaxBlobSize+1)
+	for _, col := range []string{"D", "B"} {
+		_, err := tbl.InsertRow([]types.FieldValue{
+			{FieldName: []byte("I"), Value: uint64(1)},
+			{FieldName: []byte(col), Value: huge},
+		})
+		if err == nil {
+			t.Fatalf("%s: 超过单块上限 %d 的写入必须被拒", col, MaxBlobSize)
+		}
+		if !errors.Is(err, types.ErrValueRange) {
+			t.Errorf("%s: 超限应报 ErrValueRange, 得到 %v", col, err)
+		}
+		if !strings.Contains(err.Error(), col) {
+			t.Errorf("%s: 超限报错应包含列名: %v", col, err)
+		}
+		if !strings.Contains(err.Error(), strconv.Itoa(int(MaxBlobSize))) {
+			t.Errorf("%s: 超限报错应包含上限 %d: %v", col, MaxBlobSize, err)
+		}
+	}
+}
+
+// TestNotNullGate 非空列写 NULL 必须报错, 且定长列与动态列口径一致。
+//
+// 原先只有动态列会"静默成功" (行内引用全 0 且 NULL 位图不置位, 读回来是空内容),
+// 而定长列写 nil 会在 EncodeValue 里报错 —— 同一句"写 nil"在两种列上行为相反。
+func TestNotNullGate(t *testing.T) {
+	db := fixOpenDB(t)
+	if _, err := db.CreateTable("NN", []types.FieldInfo{
+		{FieldType: types.FieldType_8byte, FieldName: []byte("I")},
+		{FieldType: types.FieldType_Dynamicbyte, FieldName: []byte("D")},
+		{FieldType: types.FieldType_Dynamicbyte, FieldName: []byte("N"), Flag: types.FlagNullable},
+	}); err != nil {
+		t.Fatalf("create: %v", err)
+	}
+	tbl, ok := db.FindTable("NN")
+	if !ok {
+		t.Fatal("找不到 NN")
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("create pk: %v", err)
+	}
+
+	// 非空动态列写 NULL → 必须报错并点名列
+	_, err := tbl.InsertRow([]types.FieldValue{
+		{FieldName: []byte("I"), Value: uint64(1)},
+		{FieldName: []byte("D"), Value: nil},
+	})
+	if err == nil {
+		t.Fatal("非空动态列写 NULL 必须被拒")
+	}
+	if !strings.Contains(err.Error(), "D") {
+		t.Errorf("非空列写 NULL 的报错应点名列: %v", err)
+	}
+
+	// 可空列写 NULL → 放行
+	if _, err := tbl.InsertRow([]types.FieldValue{
+		{FieldName: []byte("I"), Value: uint64(2)},
+		{FieldName: []byte("N"), Value: nil},
+	}); err != nil {
+		t.Errorf("可空列写 NULL 应当放行: %v", err)
+	}
+
+	// 省略非空列 (本次不传) → 放行, 按零值/空内容写
+	if _, err := tbl.InsertRow([]types.FieldValue{
+		{FieldName: []byte("I"), Value: uint64(3)},
+	}); err != nil {
+		t.Errorf("省略非空列应当放行: %v", err)
+	}
+}

+ 86 - 32
DB/core/pool/buffer_pool.go

@@ -9,13 +9,18 @@ type pageEntry struct {
 	data    []byte // 页数据副本
 	dirty   bool   // 是否被修改(需要写回磁盘)
 	pageLen int    // 页字节长度(用于计算文件偏移)
+	ver     uint64 // 内容版本号: 每次内容被写入都换一个 (见 BufferPool.stampLocked)
 }
 
 // DirtyPage 脏页快照
+//
+// ver 是快照当时该页的版本号, 只在 pool 内部使用。提交成功后 MarkClean 靠它判断
+// 「这页在提交窗口里有没有被再改过」: 改过就必须继续留脏, 不能被顺手标成干净页。
 type DirtyPage struct {
 	PageID  uint32
 	Data    []byte
 	PageLen int
+	ver     uint64
 }
 
 // BufferPool 页面缓冲池
@@ -30,6 +35,7 @@ type BufferPool struct {
 	dirtyThreshold int                                   // 脏页阈值 (0=不限制)
 	onThreshold    func()                                // 脏页超阈值回调
 	dirtyCount     int                                   // 当前脏页数 (实时追踪)
+	verSeq         uint64                                // 版本号发放器 (见 stampLocked)
 	diskRead       func(pageID uint32, buf []byte) error // 底层磁盘读
 	diskWrite      func(pageID uint32, buf []byte) error // 底层磁盘写
 
@@ -104,17 +110,22 @@ func (bp *BufferPool) waitForWriteLock() {
 	<-ch
 }
 
-// CollectDirty 取出所有脏页快照,并把它们移出缓存 (由 DB.Commit() 调用,配合 WAL 流程统一写回)。
+// CollectDirty 取出所有脏页快照 (由 DB.Commit() 调用,配合 WAL 流程统一写回)。
 //
-// 只移除「脏页」:干净页的内容与磁盘一致,保留在池中可继续命中,不必每次提交都丢弃。
-// 旧实现无论脏净一律 delete,等于每次 Commit 都把整个缓冲池清空 —— 提交后的读取全部
-// 回落到磁盘,在高频自动提交 (dirtyThreshold) 下会造成持续的读放大。
+// 快照之后【不把页移出缓存】。移出会造出一个「既不在缓存、也还没落盘」的窗口: 从
+// 收集完到 wal.Commit() 逐页回写完成之间要经过两次 fsync, 这期间任何缓存未命中的读
+// 都只能回源磁盘, 于是拿到的是旧内容 —— 或者, 对一页本次会话刚分配、盘上从来没写过
+// 的页, 拿到全零 (文件还没长到那个偏移时直接读出错)。btree 把全零页当成
+// EntryLen==0 的节点, 随后在 btPID 里切片越界 panic; 读出错则表现为
+// "insert: load failed"。页留在缓存里, 并发读就一定命中最新内容, 不存在两头都不在的时刻。
 //
-// 安全性: 干净页 ⟺ 缓存内容 == 磁盘内容。WAL 回写只会覆盖本次快照里的脏页,而它们
-// 已被移出缓存,因此保留下来的干净页不会与磁盘产生分歧。容量由 WritePage/UpdatePage
-// 中的 evictCleanForRoomLocked 约束,干净页不会让池无限膨胀。
+// 也不再清空 dirtyCount: 快照里的页此刻【尚未落盘】, 计数必须继续按"未持久化"算。
+// 页的脏标记由提交方在 wal.Commit() 成功之后用 MarkClean 收掉 —— 只有快照后没被再改过
+// (ver 未变) 的页才会转成干净页, 改过的继续留脏, 等下一次提交。
+// 干净页照旧保留在池中继续命中 (旧实现无论脏净一律 delete, 等于每次提交都清空缓存,
+// 提交后的读取全部回落到磁盘); 容量仍由 evictCleanForRoomLocked 约束。
 func (bp *BufferPool) CollectDirty() []DirtyPage {
-	// 1. 锁定写入,防止新的写操作
+	// 锁定写入,防止新的写操作
 	bp.LockWrites()
 	defer bp.UnlockWrites()
 
@@ -126,25 +137,54 @@ func (bp *BufferPool) CollectDirty() []DirtyPage {
 		}
 		data := make([]byte, len(entry.data))
 		copy(data, entry.data)
-		dirty = append(dirty, DirtyPage{pid, data, entry.pageLen})
-		delete(bp.pages, pid)
+		dirty = append(dirty, DirtyPage{PageID: pid, Data: data, PageLen: entry.pageLen, ver: entry.ver})
 	}
-	bp.dirtyCount = 0 // 重置脏页计数
+	// 这里没有释放任何页, 所以不唤醒等待容量的 goroutine (快照只是复制)。
+	bp.mu.Unlock()
+	return dirty
+}
 
-	// 2. 通知等待容量的 goroutine
-	if bp.capacityWait != nil {
-		close(bp.capacityWait)
-		bp.capacityWait = nil
+// MarkClean 把「确实已经落盘」的快照页转成干净页 (提交成功后由提交方调用)。
+//
+// 判据是版本号而不是"提交成功了": 提交窗口里写入线程可能已经把这一页又改了一遍
+// (缓存里是更新的内容, 而本次 WAL 记的是快照那一份)。这种页必须继续留脏 —— 否则它
+// 的新内容既不会被写盘, 脏标记还向引擎报告"已经持久化了", 该页一旦被淘汰就永久丢失。
+//
+// 快照里的页若在窗口里被别的路径丢弃 (回收 / 直写盘), 这里跳过即可: 那些路径自己
+// 负责缓存一致性。
+func (bp *BufferPool) MarkClean(pages []DirtyPage) {
+	if len(pages) == 0 {
+		return
+	}
+	bp.mu.Lock()
+	defer bp.mu.Unlock()
+	for _, p := range pages {
+		entry, ok := bp.pages[p.PageID]
+		if !ok || !entry.dirty || entry.ver != p.ver {
+			continue
+		}
+		entry.dirty = false
+		bp.dirtyCount--
 	}
+	if bp.dirtyCount < 0 {
+		bp.dirtyCount = 0
+	}
+}
 
-	bp.mu.Unlock()
-	return dirty
+// stampLocked 给页条目盖一个新的版本号。调用方必须持有 bp.mu。
+//
+// 版本号只用于回答一个问题: "从我拍快照到现在, 这一页被改过吗"。
+func (bp *BufferPool) stampLocked(e *pageEntry) {
+	bp.verSeq++
+	e.ver = bp.verSeq
 }
 
-// RestoreDirty 将提交失败后的脏页快照写回缓存
+// RestoreDirty 提交失败时兜底: 保证这批页在缓存里仍然是脏的。
+//
+// CollectDirty 现在不再把页移出缓存, 所以正常路径上这里无事可做 —— 留着是因为错误
+// 处理期间别的动作 (页回收 / 直写盘) 也可能碰过这些页。只补【缺失】的条目: 页已经在
+// 缓存里就原样不动, 免得用一份旧快照盖掉提交窗口里写入线程刚改出来的新内容。
 //
-// 与 CollectDirty 配对: CollectDirty 会把脏页从缓存移出, 若之后的 WAL 提交失败,
-// 必须把快照放回去, 否则这部分修改既不在内存也未落盘, 会被静默丢弃。
 // 这里刻意不触发阈值回调, 避免在错误处理路径上递归触发 Commit。
 func (bp *BufferPool) RestoreDirty(pages []DirtyPage) {
 	if len(pages) == 0 {
@@ -153,15 +193,14 @@ func (bp *BufferPool) RestoreDirty(pages []DirtyPage) {
 	bp.mu.Lock()
 	defer bp.mu.Unlock()
 	for _, p := range pages {
-		bp.pages[p.PageID] = &pageEntry{data: p.Data, dirty: true, pageLen: p.PageLen}
-	}
-	dirty := 0
-	for _, e := range bp.pages {
-		if e.dirty {
-			dirty++
+		if _, ok := bp.pages[p.PageID]; ok {
+			continue // 已在缓存 (很可能还带着更新的内容), 不要覆盖
 		}
+		e := &pageEntry{data: p.Data, dirty: true, pageLen: p.PageLen}
+		bp.stampLocked(e)
+		bp.pages[p.PageID] = e
+		bp.dirtyCount++
 	}
-	bp.dirtyCount = dirty
 }
 
 // DiscardPages 丢弃指定页的缓存 (不落盘), 并释放其占用容量。
@@ -224,12 +263,18 @@ func (bp *BufferPool) DiscardPage(pageID uint32) {
 	bp.mu.Unlock()
 }
 
-// Flush 将所有脏页写入磁盘 (仅在 Close 时使用)
+// Flush 将所有脏页写入磁盘 (Close / 维护操作时使用), 并把落盘成功的页转成干净页。
+//
+// 收尾必须走 MarkClean: CollectDirty 不再移出缓存, 少了这一步, 刚写完的页会一直带着
+// 脏标记, 下一次 Commit 会把同一批内容再写一遍 (并且写进 WAL)。
+// 写盘错误在这里被忽略 (与调用方既有约定一致), 因此按"已落盘"收尾; ver 比对仍会放过
+// 提交窗口里被改过的页, 它们继续留脏, 交给下一次 Flush / Commit。
 func (bp *BufferPool) Flush() {
 	dirty := bp.CollectDirty()
 	for _, item := range dirty {
 		_ = bp.diskWrite(item.PageID, item.Data)
 	}
+	bp.MarkClean(dirty)
 }
 
 // Close 关闭缓冲池 (刷盘后停止)
@@ -243,7 +288,9 @@ func (bp *BufferPool) UpdatePage(pageID uint32, data []byte) {
 	defer bp.mu.Unlock()
 	cached := make([]byte, len(data))
 	copy(cached, data)
-	bp.pages[pageID] = &pageEntry{data: cached, dirty: false, pageLen: len(data)}
+	e := &pageEntry{data: cached, dirty: false, pageLen: len(data)}
+	bp.stampLocked(e) // 内容换过了, 版本号必须往前走, 否则旧快照会误认为这页没变
+	bp.pages[pageID] = e
 	// 回填的页是干净页, 不受 dirtyCount 约束; 不加这一步, 大量回收页会把缓存
 	// 无限撑大 (回收 N 页 → 缓存增加 N 页), 并让 WritePage 的容量判断形同虚设。
 	bp.evictCleanForRoomLocked()
@@ -271,8 +318,12 @@ func (bp *BufferPool) evictCleanForRoomLocked() {
 	}
 }
 
-// ClearAll 清除所有脏标记 (WAL 提交后调用)
-// 保留干净页在缓存中, 避免下次 WritePage 重新缓存导致 BufferPool 反复膨胀
+// ClearAll 清除所有脏标记, 保留页在缓存中。
+//
+// ⚠️ 这是"无差别"操作, 只适用于确定没有并发写入的维护场景 (关闭 / 建表删表等)。
+// 提交路径上【不能】用它: 提交窗口里被写入线程重新改脏的页并不在本笔 WAL 里, 一并
+// 标干净就等于宣布"已持久化", 那份新内容以后既不会被写盘, 也会被淘汰逻辑当作可以
+// 直接丢弃的干净页。提交成功要收脏标记请用 MarkClean (按版本号逐页判定)。
 func (bp *BufferPool) ClearAll() {
 	bp.mu.Lock()
 	defer bp.mu.Unlock()
@@ -324,9 +375,12 @@ func (bp *BufferPool) WritePage(pageID uint32, buf []byte) error {
 	if exists && existing.dirty {
 		// 已是脏页, 直接覆盖, 不增加脏页计数
 		existing.data = cached
+		bp.stampLocked(existing) // 内容变了: 让提交窗口里的旧快照认不出这一页
 	} else {
 		// 新页或干净页, 增加脏页计数
-		bp.pages[pageID] = &pageEntry{data: cached, dirty: true, pageLen: len(buf)}
+		e := &pageEntry{data: cached, dirty: true, pageLen: len(buf)}
+		bp.stampLocked(e)
+		bp.pages[pageID] = e
 		bp.dirtyCount++
 	}
 

+ 101 - 27
DB/core/pool/buffer_pool_test.go

@@ -35,52 +35,126 @@ func (d *diskStub) write(pageID uint32, buf []byte) error {
 	return nil
 }
 
-// TestCollectDirtyKeepsCleanPages 验证 CollectDirty 只取出并移出脏页,
-// 与磁盘一致的干净页留在池中继续命中。
+// TestCollectDirtyKeepsDirtyPagesReadable 收集脏页快照之后, 脏页必须【仍然留在缓存里】
+// 且内容可读 —— 这是"并发写 + 提交"崩溃的直接回归点。
 //
-// 回归点: 旧实现无论脏净一律 delete,等于每次 Commit 都把整个缓冲池清空,
-// 提交后的读取全部回落到磁盘。
-func TestCollectDirtyKeepsCleanPages(t *testing.T) {
+// 旧实现把脏页移出缓存, 于是从收集完到 WAL 逐页回写完成(中间隔着两次 fsync)这段窗口
+// 里, 这些页既不在缓存、也还没落盘: 并发的读一旦未命中就回源, 拿到的是旧内容, 对
+// 一页本会话刚分配、盘上从没写过的页则直接是全零。btree 把全零页当成 EntryLen==0 的
+// 节点, 接着就在 btPID 里切片越界 panic; 文件还没长到那个偏移时则读出错, 表现为
+// "insert: load failed"。
+//
+// 注意旧版本的这条用例恰好把 bug 当成预期钉住了: 它断言"脏页回落到磁盘, 期望 0"。
+func TestCollectDirtyKeepsDirtyPagesReadable(t *testing.T) {
 	bp, _ := newTestPool(t, 64)
 
-	_ = bp.WritePage(1, []byte{1})    // 脏页
-	bp.UpdatePage(2, []byte{9, 9})    // 干净页 (WAL 回填/回收页回填)
-	_ = bp.WritePage(3, []byte{7})    // 脏页
+	_ = bp.WritePage(1, []byte{1}) // 脏页 (盘上还没有)
+	bp.UpdatePage(2, []byte{9, 9}) // 干净页 (WAL 回填/回收页回填)
+	_ = bp.WritePage(3, []byte{7}) // 脏页 (盘上还没有)
 
-	if bp.Len() != 3 {
-		t.Fatalf("收集前期望池内 3 页, 实际 %d", bp.Len())
-	}
-	if bp.DirtyCount() != 2 {
-		t.Fatalf("收集前期望脏页 2, 实际 %d", bp.DirtyCount())
+	if bp.Len() != 3 || bp.DirtyCount() != 2 {
+		t.Fatalf("收集前期望池内 3 页 / 脏页 2, 实际 %d / %d", bp.Len(), bp.DirtyCount())
 	}
 
 	dirty := bp.CollectDirty()
 	if len(dirty) != 2 {
 		t.Fatalf("期望取出 2 个脏页, 实际 %d", len(dirty))
 	}
-	if bp.DirtyCount() != 0 {
-		t.Fatalf("收集后脏页计数应清零, 实际 %d", bp.DirtyCount())
+
+	// 快照不等于"页离开了池子": 页数与脏页数都不该变。
+	if bp.Len() != 3 {
+		t.Fatalf("收集后页仍应全部留在池中, 实际 %d", bp.Len())
 	}
-	if bp.Len() != 1 {
-		t.Fatalf("收集后应仅保留 1 个干净页, 实际 %d", bp.Len())
+	if bp.DirtyCount() != 2 {
+		t.Fatalf("收集后脏页计数不该清零 (它们还没落盘), 实际 %d", bp.DirtyCount())
 	}
 
-	// 干净页 2 仍在缓存中 → 命中,读到回填值
-	out := make([]byte, 16)
-	if err := bp.ReadPage(2, out); err != nil {
+	// 干净页 2 命中缓存
+	out2 := make([]byte, 16)
+	if err := bp.ReadPage(2, out2); err != nil {
 		t.Fatalf("ReadPage(2): %v", err)
 	}
-	if out[0] != 9 || out[1] != 9 {
-		t.Fatalf("干净页应命中缓存并返回回填值, 实际 %v", out[:2])
+	if out2[0] != 9 || out2[1] != 9 {
+		t.Fatalf("干净页应命中缓存并返回回填值, 实际 %v", out2[:2])
+	}
+
+	// 脏页 1 / 3 也必须命中缓存并读到真实内容 —— 拿到 0 就说明又出现"两头都不在"。
+	for _, c := range []struct {
+		pageID uint32
+		want   byte
+	}{{1, 1}, {3, 7}} {
+		out := make([]byte, 16)
+		if err := bp.ReadPage(c.pageID, out); err != nil {
+			t.Fatalf("ReadPage(%d): %v", c.pageID, err)
+		}
+		if out[0] != c.want {
+			t.Fatalf("收集快照后读取页 %d 应当是 %d, 实际 %d (页被移出缓存就会出现这种全零读)",
+				c.pageID, c.want, out[0])
+		}
 	}
+}
 
-	// 脏页 1 已被移出缓存 → 回落到磁盘 (该页从未落盘, 内容为零)
-	out1 := make([]byte, 16)
-	if err := bp.ReadPage(1, out1); err != nil {
+// TestMarkCleanOnlyClearsUnchangedPages 提交成功收脏标记时必须按版本号逐页判定:
+// 提交窗口里被写入线程重新改过的页要继续留脏。
+//
+// 反例就是 ClearAll 式的"提交成功 → 整个缓存标干净": 那些页并不在本笔 WAL 里, 标干净
+// 等于宣布"已持久化" —— 新内容再也不会被写盘, 页被淘汰时静默消失。
+func TestMarkCleanOnlyClearsUnchangedPages(t *testing.T) {
+	bp, _ := newTestPool(t, 64)
+
+	_ = bp.WritePage(1, []byte{1}) // 会被重新改脏
+	_ = bp.WritePage(2, []byte{2}) // 保持原样
+	dirty := bp.CollectDirty()
+	if len(dirty) != 2 {
+		t.Fatalf("期望 2 个脏页, 实际 %d", len(dirty))
+	}
+
+	// 模拟"提交进行中, 写入线程又改了页 1"
+	_ = bp.WritePage(1, []byte{0xFF})
+
+	// 提交成功 → WAL 回填步骤 (WritePageHome) 已在盘上写下快照内容
+	bp.MarkClean(dirty)
+
+	if bp.DirtyCount() != 1 {
+		t.Fatalf("被重新改脏的页必须继续留脏, 期望脏页 1, 实际 %d", bp.DirtyCount())
+	}
+	out := make([]byte, 16)
+	if err := bp.ReadPage(1, out); err != nil {
 		t.Fatalf("ReadPage(1): %v", err)
 	}
-	if out1[0] != 0 {
-		t.Fatalf("脏页应已移出缓存并回落到磁盘, 期望 0, 实际 %d", out1[0])
+	if out[0] != 0xFF {
+		t.Fatalf("页 1 应保留提交窗口里写入的新内容 0xFF, 实际 %d", out[0])
+	}
+	// 页 2 没被改过 → 转干净页 (内容不受影响)
+	if entry, ok := bp.pages[2]; !ok || entry.dirty {
+		t.Fatalf("未被改动的页应转成干净页, 实际 %+v (存在=%v)", entry, ok)
+	}
+}
+
+// TestFlushWritesAndClearsDirty Flush 必须真的落盘, 并且把写出去的页转成干净页 ——
+// CollectDirty 不再移出缓存, 少了收尾这一步, 同一批内容会被下一次提交再写一遍。
+func TestFlushWritesAndClearsDirty(t *testing.T) {
+	bp, disk := newTestPool(t, 64)
+
+	_ = bp.WritePage(1, []byte{0x11})
+	_ = bp.WritePage(2, []byte{0x22})
+
+	bp.Flush()
+
+	if bp.DirtyCount() != 0 {
+		t.Fatalf("Flush 后不该还有脏页, 实际 %d", bp.DirtyCount())
+	}
+	for _, c := range []struct {
+		pageID uint32
+		want   byte
+	}{{1, 0x11}, {2, 0x22}} {
+		got, ok := disk.pages[c.pageID]
+		if !ok {
+			t.Fatalf("页 %d 没有被写盘", c.pageID)
+		}
+		if got[0] != c.want {
+			t.Fatalf("页 %d 落盘内容应当是 %#x, 实际 %#x", c.pageID, c.want, got[0])
+		}
 	}
 }
 

+ 17 - 6
DB/core/query.go

@@ -69,8 +69,12 @@ func (q *Query) OrderBy(field string, descending ...bool) *Query {
 // 因此"索引键字节 == 行内字节"是范围扫描正确的前提。这里不再按类型手写分支 ——
 // 以前每加一个类型都要在这里补一份, 漏补的后果是索引选出来的行与过滤结果不一致。
 //
-// param 是列级类型参数 (FieldType_Decimal 的小数位数): 少了它就没法把条件值编成
-// 与行内完全一致的字节 (同一列 scale 必须一致, 见 types.FieldType_Decimal)。
+// param 是列级类型参数: decimal 是小数位数, 字符串家族是行内宽度码。少了它就没法把
+// 条件值编成与行内完全一致的字节 —— decimal 会因 scale 不同而长度/系数不一致,
+// 字符串则会按错误的宽度截断或越界, 两种都让"索引选出 5 行、过滤后剩 0 行"。
+//
+// 键长必须等于 FieldSize(ft, param): B+ 树按【定长拼键】比较, 键长与行内宽度不一致
+// 就等于拿两种编码去比大小。
 func encodeIndexKey(value interface{}, ft types.FieldType, param byte) []byte {
 	if types.IsDynamic(ft) {
 		// 动态列不能作为索引分量 (CreateIndex 已拒绝), 这里仅作兜底
@@ -79,7 +83,13 @@ func encodeIndexKey(value interface{}, ft types.FieldType, param byte) []byte {
 		}
 		return nil
 	}
-	buf := make([]byte, types.GetSize(ft))
+	size := types.FieldSize(ft, param)
+	if size == 0 {
+		// 未注册类型 / 非法列参数: 交给下面 EncodeValue 报出具体原因 (buf 长度为 0)
+		panic(fmt.Errorf("%w: 类型 %s 的宽度不可计算 (列级参数 %d)",
+			types.ErrValueType, types.TypeNameOf(ft), param))
+	}
+	buf := make([]byte, size)
 	if err := types.EncodeValue(ft, param, value, buf); err != nil {
 		// 沿用既有约定: panic 由 encodeIndexKeySafe* 转成 error。
 		panic(err)
@@ -254,7 +264,8 @@ func (q *Query) buildKeyLayout(meta *IndexMeta) (*indexKeyLayout, bool) {
 		if types.IsDynamic(ft) {
 			return nil, false
 		}
-		size := int(getFieldSize(ft))
+		// 键分量宽度必须与行内宽度同源 (含字符串列的宽度码), 否则索引键的偏移切不出该列
+		size := int(q.table.Field[fi].Width())
 		if size <= 0 {
 			return nil, false
 		}
@@ -406,8 +417,8 @@ func (q *Query) planIndex(m *IndexMeta, handler index.IndexHandler, leaves []Con
 		ft := q.table.Field[m.FieldIDs[k]].FieldType
 		// 用 Safe 版本: 条件的值类型与列存储类型不符时 encodeIndexKey 会 panic,
 		// 规划阶段不该把用户输入直接变成崩溃 —— 退回行级过滤由 Match 去处理。
-		// 带上列级 param (decimal 的 scale): 索引键必须与行内字节完全一致。
-		enc, err := encodeIndexKeySafeWithParam(value, ft, q.table.fieldScale(int(m.FieldIDs[k])))
+		// 带上列级 param (decimal 的 scale / 字符串的宽度码): 索引键必须与行内字节完全一致。
+		enc, err := encodeIndexKeySafeWithParam(value, ft, q.table.fieldParam(int(m.FieldIDs[k])))
 		if err != nil || len(enc) != lay.sizes[k] {
 			// 编码失败, 或长度与分量长度不符 (拼进定长键会错位) → 交给行级过滤
 			rest = append(rest, c)

+ 307 - 0
DB/core/stringwidth_test.go

@@ -0,0 +1,307 @@
+package core
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"strings"
+	"testing"
+
+	"TsDB2/DB/core/page"
+	"TsDB2/DB/core/types"
+)
+
+// 本文件是"字符串列宽度由列级参数决定"这一改动的端到端验证。
+//
+// 要钉住的性质只有一条, 但它极其容易静默失效: **行内字节与索引键字节同源**。
+// 宽度算错的后果不是报错, 而是该列之后的每一个字段都从错位偏移解码 —— 行照样能读,
+// 字段也都有值, 只是全是别的字段的内容。所以这里除了校验被加宽的列本身, 还特意放了
+// 两个"错位探针" (After / Idx): 只有它们的值也对, 才说明布局是对的。
+
+// stringTrim 定长字符串列解码出来是"补满列宽"的字节, 比较前去掉补零。
+func stringTrim(v interface{}) string {
+	b, ok := v.([]byte)
+	if !ok {
+		return fmt.Sprintf("<非字节载体 %T>", v)
+	}
+	return string(bytes.TrimRight(b, "\x00"))
+}
+
+// TestStringColumnWidthEndToEnd 宽度码列: 写入/读取/索引/超宽报错。
+func TestStringColumnWidthEndToEnd(t *testing.T) {
+	db := fixOpenDB(t)
+
+	code := types.WidthCodeFor(1024) // 声明 1024 字节
+	width := int(types.FieldSize(types.FieldType_256byte, code))
+	if width < 1024 {
+		t.Fatalf("宽度码 %d 只给出 %d 字节, 装不下声明的 1024", code, width)
+	}
+
+	tbl, err := db.CreateTable("WSTR", []types.FieldInfo{
+		{FieldType: types.FieldType_8byte, FieldName: []byte("ID")},
+		{FieldType: types.FieldType_256byte, Flag: code, FieldName: []byte("Wide")},
+		// Wide2 与 Wide 同宽但【不建索引】: 用来比对"索引路径"与"全表过滤路径"
+		{FieldType: types.FieldType_256byte, Flag: code, FieldName: []byte("Wide2")},
+		// After / Idx 是错位探针: Wide 的宽度算错时先烂的是它们
+		{FieldType: types.FieldType_64byte, FieldName: []byte("After")},
+		{FieldType: types.FieldType_8byte, FieldName: []byte("Idx")},
+	})
+	if err != nil {
+		t.Fatalf("create table: %v", err)
+	}
+	// 主键索引 = 承载行数据的行存储: 没有它 InsertRow 无处落行 (静默丢行), 全表扫也读不到
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("create pk: %v", err)
+	}
+	if _, err := tbl.CreateIndex("idx_wide", page.IndexTypeBTree, []byte{1}); err != nil {
+		t.Fatalf("在宽度码列上建索引: %v", err)
+	}
+
+	// 1 字节 / 500 字节 / 正好填满列宽: 最后一档覆盖"边界处不允许溢出"
+	vals := []string{"a", strings.Repeat("b", 500), strings.Repeat("c", width)}
+	after := func(i int) string { return fmt.Sprintf("%012d", 1000+i) }
+	for i, v := range vals {
+		if _, err := tbl.InsertRow([]types.FieldValue{
+			{FieldName: []byte("ID"), Value: uint64(i + 1)},
+			{FieldName: []byte("Wide"), Value: v},
+			{FieldName: []byte("Wide2"), Value: v},
+			{FieldName: []byte("After"), Value: after(i)},
+			{FieldName: []byte("Idx"), Value: uint64(7 + i)},
+		}); err != nil {
+			t.Fatalf("insert %d: %v", i, err)
+		}
+	}
+
+	rows, err := tbl.Query().OrderBy("Idx").Find()
+	if err != nil {
+		t.Fatalf("find: %v", err)
+	}
+	if len(rows) != len(vals) {
+		t.Fatalf("应读出 %d 行, 得到 %d", len(vals), len(rows))
+	}
+	for i, row := range rows {
+		if got := stringTrim(newTypeRowVal(t, row, "Wide")); got != vals[i] {
+			t.Errorf("第 %d 行 Wide 长度 %d, 期望长度 %d (内容不一致)",
+				i, len(got), len(vals[i]))
+		}
+		if got := stringTrim(newTypeRowVal(t, row, "Wide2")); got != vals[i] {
+			t.Errorf("第 %d 行 Wide2 长度 %d, 期望长度 %d", i, len(got), len(vals[i]))
+		}
+		// 这两行才是"宽度正确"的真正证据
+		if got := stringTrim(newTypeRowVal(t, row, "After")); got != after(i) {
+			t.Errorf("第 %d 行 After = %q, 期望 %q —— 宽度算错时这里会先烂",
+				i, got, after(i))
+		}
+		if got := newTypeRowVal(t, row, "Idx"); got != uint64(7+i) {
+			t.Errorf("第 %d 行 Idx = %v, 期望 %d", i, got, 7+i)
+		}
+	}
+
+	// 索引路径 (Wide) 与全表过滤路径 (Wide2) 必须给出同一个答案
+	probe := vals[1]
+	viaIdx, err := tbl.Query().Where(Eq("Wide", probe, types.FieldType(0))).Count()
+	if err != nil {
+		t.Fatalf("索引路径查询: %v", err)
+	}
+	viaScan, err := tbl.Query().Where(Eq("Wide2", probe, types.FieldType(0))).Count()
+	if err != nil {
+		t.Fatalf("全表过滤查询: %v", err)
+	}
+	if viaIdx != 1 || viaScan != 1 {
+		t.Fatalf("索引路径 %d 行 / 全表过滤 %d 行, 都应为 1 (不一致即行内字节与索引键不同源)",
+			viaIdx, viaScan)
+	}
+
+	// 定长列的比较是【整列宽】比较: 128 字节的 'b' 前缀不能命中 500 字节的行
+	if n, _ := tbl.Query().Where(Eq("Wide", strings.Repeat("b", 128), types.FieldType(0))).Count(); n != 0 {
+		t.Errorf("前缀 %q 不该命中 500 字节的行, 却命中了 %d 行 (比较没有按列宽补零)",
+			strings.Repeat("b", 8)+"...", n)
+	}
+
+	// ===== 超宽: 报错, 不截断 (旧行为是静默截断: 写 20 字节的列声明 10 也会成功落库) =====
+	before, _ := tbl.Query().Count()
+	_, err = tbl.InsertRow([]types.FieldValue{
+		{FieldName: []byte("ID"), Value: uint64(99)},
+		{FieldName: []byte("Wide"), Value: strings.Repeat("d", width+1)},
+		{FieldName: []byte("Wide2"), Value: "x"},
+		{FieldName: []byte("After"), Value: after(99)},
+		{FieldName: []byte("Idx"), Value: uint64(99)},
+	})
+	if !errors.Is(err, types.ErrValueRange) {
+		t.Fatalf("写入 %d 字节 (列宽 %d) 应报 ErrValueRange, 得到 %v", width+1, width, err)
+	}
+	// 失败不能留下半条行, 也不能让表变得不可用
+	if after, _ := tbl.Query().Count(); after != before {
+		t.Errorf("写失败后行数从 %d 变成 %d: 失败的行有残留", before, after)
+	}
+	if n, _ := tbl.Query().Where(Eq("Idx", uint64(99), types.FieldType(0))).Count(); n != 0 {
+		t.Errorf("被判超宽的行竟然进了表 (命中 %d 行)", n)
+	}
+	if _, err := tbl.InsertRow([]types.FieldValue{
+		{FieldName: []byte("ID"), Value: uint64(100)},
+		{FieldName: []byte("Wide"), Value: "ok"},
+		{FieldName: []byte("Wide2"), Value: "ok"},
+		{FieldName: []byte("After"), Value: after(100)},
+		{FieldName: []byte("Idx"), Value: uint64(100)},
+	}); err != nil {
+		t.Errorf("超宽写入失败之后, 正常写入也失败了: %v", err)
+	}
+}
+
+// TestRowSizeGate 整行占用闸门: 建表 / 加列 / 写行三处都要拦, 且错误要说清哪几列占了多少。
+//
+// 判据不是"字段区 ≤ 8K": 4 × 2048 = 8192 的字段区看着正好 8K, 加上每行固定开销与 NULL
+// 位图之后就装不下两行了, 所以必须拒。
+func TestRowSizeGate(t *testing.T) {
+	db := fixOpenDB(t)
+	code := types.WidthCodeFor(types.MaxInlineColumnWidth) // 每列 2048 字节
+	wide := func(name string) types.FieldInfo {
+		return types.FieldInfo{
+			FieldType: types.FieldType_256byte, Flag: code, FieldName: []byte(name),
+		}
+	}
+
+	// 3 × 2048 = 6144 字段区: 加上开销仍装得下两行, 允许建表
+	if _, err := db.CreateTable("NARROW", []types.FieldInfo{
+		wide("A"), wide("B"), wide("C"),
+	}); err != nil {
+		t.Fatalf("3 × 2048 建表: %v", err)
+	}
+	at, ok := db.FindTable("NARROW")
+	if !ok {
+		t.Fatal("找不到 NARROW")
+	}
+	// 行存储索引 (主键): 下面"宽行仍能写入"必须真的落行, 否则 InsertRow 会静默丢行
+	if _, err := at.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("create pk: %v", err)
+	}
+	// 6144 + 2048 = 8192 字段区: 一页装不下两行, AddField 必须被拒
+	if err := at.AddField(wide("D")); err == nil {
+		t.Errorf("4 × 2048 = 8192 的字段区装不下两行 (上限 %d), AddField 必须被拒", types.MaxRowDataSize)
+	} else if !strings.Contains(err.Error(), "行宽") {
+		t.Errorf("超宽错误应说明是行宽问题: %v", err)
+	}
+	// 被拒的列不能进 schema, 表本身也必须还能用
+	if _, present := at.GetActiveFieldType("D"); present {
+		t.Errorf("行宽超限的列 D 被加进了 schema")
+	}
+	if _, err := at.InsertRow([]types.FieldValue{
+		{FieldName: []byte("A"), Value: "a"},
+		{FieldName: []byte("B"), Value: "b"},
+		{FieldName: []byte("C"), Value: "c"},
+	}); err != nil {
+		t.Errorf("闸门内的表应当仍能写入: %v", err)
+	} else if n, _ := at.Query().Count(); n != 1 {
+		t.Errorf("写入成功却读不到行 (读到 %d 行): 宽行没有真正落盘", n)
+	}
+
+	// 整表建的时候就超: 必须拒绝, 并列出各列宽度 (只说"超了"没法用, 用户要知道砍哪一列)
+	_, err := db.CreateTable("OVER", []types.FieldInfo{
+		wide("A"), wide("B"), wide("C"), wide("D"),
+	})
+	if err == nil {
+		t.Fatalf("4 × 2048 = 8192 的字段区超过 %d, 必须拒绝建表", types.MaxRowDataSize)
+	}
+	for _, want := range []string{"行宽", "D=2048", "8192"} {
+		if !strings.Contains(err.Error(), want) {
+			t.Errorf("建表超宽的错误信息缺少 %q: %v", want, err)
+		}
+	}
+}
+
+// TestMaxRowDataSizeFitsTwoRows 建列闸门推出的上限必须真的"一页两行"。
+//
+// 这是 types.MaxRowDataSize 的【唯一来由】(推导见那里的注释), 但那个上限算在 types 包里、
+// 用的是页面布局的镜像值, 所以在这里用真实的 PageSize / DataPageHdrSize 复核一遍: 镜像一旦
+// 与布局漂移, 上限就变成空头承诺 —— 建表时放行, 写第二行时才发现装不下。
+//
+// 顺带把 8192 钉在"拦得住"的那一侧: 8K 是个好看的数, 但每行还有固定开销与 NULL 位图,
+// 两行合计会越过页里能用的字节数 —— 旧上限就是这么来的, 这个断言防止它被改回去。
+func TestMaxRowDataSizeFitsTwoRows(t *testing.T) {
+	// 一页能放行的行字节数: 末字节用不上 (InsertRowSortedByOffset 用 ">=" 提前判失败)
+	avail := int(PageSize) - int(page.DataPageHdrSize) - 1
+
+	// 上限本身: 两行正好装得下
+	if 2*types.MaxRowDataSize > avail {
+		t.Errorf("上限 %d 的两行 (%d 字节) 装不进一页 (%d)",
+			types.MaxRowDataSize, 2*types.MaxRowDataSize, avail)
+	}
+	// 且它是满足该性质的【最大】值 —— 否则上限偏小, 白白少用页里一半空间
+	if 2*(types.MaxRowDataSize+1) <= avail {
+		t.Errorf("上限 %d 偏小: 再宽 1 字节的两行 (%d) 仍装得进一页 (%d)",
+			types.MaxRowDataSize, 2*(types.MaxRowDataSize+1), avail)
+	}
+	// 旧上限 (8192 字节字段区): 加上每行开销后两行装不下 —— 它从来没兑现过这条政策
+	oldWidestRow := 8192 + 4 + 8 + 1 + types.NullBitmapLen(1)
+	if 2*oldWidestRow <= avail {
+		t.Errorf("8192 字节字段区竟然装得下两行 (%d × 2 ≤ %d): 那 8192 本来就没问题",
+			oldWidestRow, avail)
+	}
+}
+
+// TestReviveFieldKeepsWidth 复活已删除的列时不允许改类型/宽度。
+//
+// master registry 里的类型与宽度码是【所有历史版本共用】的行偏移依据: 复活时把它换掉,
+// 已经落盘的旧行仍按旧宽度排布, 却会被按新宽度解析 —— 该列之后的字段全部静默错位。
+func TestReviveFieldKeepsWidth(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl, err := db.CreateTable("REVIVE", []types.FieldInfo{
+		{FieldType: types.FieldType_8byte, FieldName: []byte("ID")},
+		{FieldType: types.FieldType_64byte, FieldName: []byte("Gone")},
+		{FieldType: types.FieldType_8byte, FieldName: []byte("Probe")},
+	})
+	if err != nil {
+		t.Fatalf("create table: %v", err)
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("create pk: %v", err)
+	}
+	if _, err := tbl.InsertRow([]types.FieldValue{
+		{FieldName: []byte("ID"), Value: uint64(1)},
+		{FieldName: []byte("Gone"), Value: "keepme"},
+		{FieldName: []byte("Probe"), Value: uint64(42)},
+	}); err != nil {
+		t.Fatalf("insert: %v", err)
+	}
+
+	if err := tbl.DropField("Gone"); err != nil {
+		t.Fatalf("drop: %v", err)
+	}
+	err = tbl.AddField(types.FieldInfo{FieldType: types.FieldType_32byte, FieldName: []byte("Gone")})
+	if err == nil {
+		t.Fatalf("复活时把 64byte 的列改成 32byte 必须被拒")
+	}
+	if !strings.Contains(err.Error(), "宽度") && !strings.Contains(err.Error(), "类型") {
+		t.Errorf("拒因应说明是类型/宽度不一致: %v", err)
+	}
+
+	// 同类型复活: 允许, 且旧行 (按旧版本布局) 与新行 (按新版本布局) 都要读得对
+	if err := tbl.AddField(types.FieldInfo{FieldType: types.FieldType_64byte, FieldName: []byte("Gone")}); err != nil {
+		t.Fatalf("同类型复活应当成功: %v", err)
+	}
+	if _, err := tbl.InsertRow([]types.FieldValue{
+		{FieldName: []byte("ID"), Value: uint64(2)},
+		{FieldName: []byte("Gone"), Value: "second"},
+		{FieldName: []byte("Probe"), Value: uint64(43)},
+	}); err != nil {
+		t.Fatalf("复活后 insert: %v", err)
+	}
+
+	rows, err := tbl.Query().OrderBy("Probe").Find()
+	if err != nil {
+		t.Fatalf("find: %v", err)
+	}
+	if len(rows) != 2 {
+		t.Fatalf("应读出 2 行, 得到 %d", len(rows))
+	}
+	wantGone := []string{"keepme", "second"}
+	for i, row := range rows {
+		if got := stringTrim(newTypeRowVal(t, row, "Gone")); got != wantGone[i] {
+			t.Errorf("第 %d 行 Gone = %q, 期望 %q (旧行/新行用了不同版本的布局)",
+				i, got, wantGone[i])
+		}
+		if got := newTypeRowVal(t, row, "Probe"); got != uint64(42+i) {
+			t.Errorf("第 %d 行 Probe = %v, 期望 %d", i, got, 42+i)
+		}
+	}
+}

+ 16 - 5
DB/core/transaction.go

@@ -90,6 +90,11 @@ func (txn *Transaction) Commit() error {
 		return fmt.Errorf("transaction already rolled back")
 	}
 
+	// 与 DB.Commit 共用同一把提交锁: 事务提交和自动提交(定时器 / 脏页阈值)也可能撞
+	// 在一起, 两笔提交同时在飞会把页的归属搞乱。详见 DB.Commit 的说明。
+	txn.db.commitMu.Lock()
+	defer txn.db.commitMu.Unlock()
+
 	// 1. 收集 BufferPool 脏页
 	dirty := txn.db.file.CollectDirty()
 	if len(dirty) == 0 {
@@ -118,23 +123,29 @@ func (txn *Transaction) Commit() error {
 	}
 
 	// 4. HeaderPage 也纳入
-	wal.RecordPage(0, marshalHeader(txn.db.header))
+	//    marshalHeader 读整张头页, 含提交路径正在改的 WAL 链指针, 必须在 headerMu 内拍快照。
+	txn.db.file.withHeader(func() {
+		wal.RecordPage(0, marshalHeader(txn.db.header))
+	})
 
 	// 5. ⭐ 关键:Commit 会触发 fsync,确保落盘 ⭐
 	if err := wal.Commit(); err != nil {
-		// 提交失败: 脏页已被 CollectDirty 移出 BufferPool, 必须写回内存,
-		// 否则这部分修改会被静默丢弃 (磁盘上也没有)
+		// 提交失败: 这批页继续留在缓存里当脏页(CollectDirty 不再移出缓存),
+		// RestoreDirty 只做兜底, 见 DB.Commit 里的说明
 		txn.db.file.RestoreDirty(dirty)
 		return fmt.Errorf("WAL commit failed: %v", err)
 	}
 
-	// 6. 提交成功 → 堆链/索引指针已持久化, 本事务登记摘链的页现在可以真正还链。
+	// 6. 提交成功 → 把这一批页收成干净页 (按快照版本号, 提交窗口里被改过的页继续留脏)
+	txn.db.file.MarkClean(dirty)
+
+	// 7. 提交成功 → 堆链/索引指针已持久化, 本事务登记摘链的页现在可以真正还链。
 	//    必须在 wal.Commit() 之后: 它内部已完成 fsync + 回写真实页。
 	txn.db.freeNow(txn.pendingFree)
 	txn.db.freeChainNow(txn.pendingFreeChain)
 	txn.db.drainPendingFree()
 
-	// 7. 清理事务状态
+	// 8. 清理事务状态
 	txn.committed = true
 	txn.db.currentTxn = nil
 	txn.touched = nil

+ 24 - 6
DB/core/types/codec.go

@@ -54,9 +54,10 @@ const (
 // maxUint64 无符号 64 位上限。
 const maxUint64 = ^uint64(0)
 
-// EncodeValue 把 Go 值编码成【行内存储字节】, len(buf) 必须等于 GetSize(ft)。
+// EncodeValue 把 Go 值编码成【行内存储字节】, len(buf) 必须等于 FieldSize(ft, param)。
 //
-// param 是列级类型参数 (FieldType_Decimal: 列声明的小数位数; 其余类型忽略)。
+// param 是列级类型参数: FieldType_Decimal 是列声明的小数位数, 字符串家族是行内宽度码
+// (见 FieldSize); 其余类型必须为 0。
 //
 // 动态类型不在这里编码 (它在行内只放 6 字节堆引用, 由 writeDynamicField 处理)。
 func EncodeValue(ft FieldType, param byte, value any, buf []byte) error {
@@ -64,9 +65,10 @@ func EncodeValue(ft FieldType, param byte, value any, buf []byte) error {
 	if IsDynamic(t) {
 		return fmt.Errorf("%w: 动态类型 (%s) 走堆页写入, 不经 EncodeValue", ErrValueType, TypeNameOf(t))
 	}
-	size := GetSize(t)
+	size := FieldSize(t, param)
 	if size == 0 {
-		return fmt.Errorf("%w: 字段类型 %d 未注册", ErrValueType, t)
+		return fmt.Errorf("%w: 字段类型 %d 的宽度不可计算 (列级参数 %d 非法)",
+			ErrValueType, t, param)
 	}
 	if len(buf) != int(size) {
 		return fmt.Errorf("%w: %s 需要 %d 字节, 传入 %d", ErrEncodeBufSize, TypeNameOf(t), size, len(buf))
@@ -214,8 +216,19 @@ func EncodeValue(ft FieldType, param byte, value any, buf []byte) error {
 		if !ok {
 			return fmt.Errorf("%w: %s 列只接受 string/[]byte, 收到 %T", ErrValueType, TypeNameOf(t), value)
 		}
-		// 超长按 CHAR(n) 语义截断 (既有行为, 与 ORM 的 toFixedBytes 一致);
-		// 数值类型绝不截断, 这是两条不同的路径。
+		// 【超宽报错, 不截断】
+		//
+		// 旧实现是 copy(buf, b) —— 超出列宽的部分被静默丢掉, 调用方拿不到任何信号:
+		// "varchar(10) 写 20 字" 会成功落库并少了一半内容, 直到某天对账才发现。
+		// 声明宽度的本意就是"这是一条硬闸门", 所以这里必须报错。
+		//
+		// 数值类型原本就不截断 (超范围一律 outOfRange), 这里只是让字符串与它们对齐:
+		// 全库只有"报错"一条路径, 不存在"某些类型会悄悄改内容"的分支。
+		if len(b) > len(buf) {
+			return fmt.Errorf("%w: %s 列宽 %d 字节, 值 %d 字节超宽 %d 字节; "+
+				"请加宽该列 (上限 %d 字节) 或改用动态列",
+				ErrValueRange, TypeNameOf(t), len(buf), len(b), len(b)-len(buf), MaxInlineColumnWidth)
+		}
 		copy(buf, b)
 	}
 	return nil
@@ -513,6 +526,11 @@ func asDecimalValue(v any) (Decimal, error) {
 		return *x, nil
 	case string:
 		return ParseDecimal(x)
+	case []byte:
+		// MySQL 的 DECIMAL/NUMERIC 在文本协议下就是 ASCII 定点文本, 只是装在
+		// []byte 里 —— 与 string 同一份内容, 同样是无损解析 (不走浮点)。
+		// 不认它, 源库里每个 decimal 列都会在迁移时撞上 "[]uint8 不能作为 decimal"。
+		return ParseDecimal(string(x))
 	}
 	rv := reflect.ValueOf(v)
 	if !rv.IsValid() {

+ 28 - 0
DB/core/types/codec_test.go

@@ -271,6 +271,34 @@ func TestDecimalParseAndString(t *testing.T) {
 	}
 }
 
+// TestDecimalFromDriverBytes 驱动把 DECIMAL 交回来时是 []byte —— MySQL 文本协议下
+// DECIMAL/NUMERIC 就是 ASCII 定点文本, 只是装在字节串里。不认这种形态, 迁移时源库里
+// 每个 decimal 列都会撞上 "[]uint8 不能作为 decimal" (真实踩到: order_config.money)。
+//
+// 顺带钉住它【不走浮点】: 字节串是精确文本, 解析后小数位必须一位不差。
+func TestDecimalFromDriverBytes(t *testing.T) {
+	cases := []struct{ in, want string }{
+		{"12.34", "12.34"},
+		{"0.10", "0.10"},
+		{"-1234567890123456.78", "-1234567890123456.78"},
+	}
+	for _, c := range cases {
+		d, err := DecimalFromValue([]byte(c.in))
+		if err != nil {
+			t.Fatalf("DecimalFromValue([]byte(%q)): %v", c.in, err)
+		}
+		if d.String() != c.want {
+			t.Errorf("DecimalFromValue([]byte(%q)) = %q, 期望 %q", c.in, d.String(), c.want)
+		}
+	}
+	// 不是合法定点数时照旧报错, 不能当成 0 混过去
+	for _, bad := range []string{"", "abc", "1.2.3"} {
+		if _, err := DecimalFromValue([]byte(bad)); err == nil {
+			t.Errorf("DecimalFromValue([]byte(%q)) 应报错", bad)
+		}
+	}
+}
+
 func TestDecimalArithmetic(t *testing.T) {
 	a, b := MustParseDecimal("0.1"), MustParseDecimal("0.2")
 	sum, err := a.Add(b)

+ 1 - 1
DB/core/types/decimal.go

@@ -337,7 +337,7 @@ func DecimalScaleOfBytes(b []byte) uint16 {
 }
 
 // DecimalFromValue 把任意可表达为 decimal 的 Go 值转成 Decimal
-// (Decimal / *Decimal / 字符串 / 整数 / 浮点, 见 asDecimalValue)。
+// (Decimal / *Decimal / 字符串 / 字节串 / 整数 / 浮点, 见 asDecimalValue)。
 //
 // 查询侧要用它把条件值转成 Decimal 再做数值比较: 条件值的 scale 可能与行内不同
 // (WHERE amount = 1.5 而列是 DECIMAL(18,2)), 这时不能用字节比较。

+ 299 - 0
DB/core/types/stringwidth_test.go

@@ -0,0 +1,299 @@
+package types
+
+import (
+	"bytes"
+	"errors"
+	"strconv"
+	"strings"
+	"testing"
+)
+
+// 本文件钉住"字符串列的行内宽度由列级参数 (Flag 低 5 位) 决定"这条规则。
+//
+// 它是整个改动里唯一会【静默破坏数据布局】的地方: 宽度算错不会报错, 只会让该列之后的
+// 每一个字段都从错位偏移解码 —— 行还是能读出来, 字段也都有值, 只是全错位。所以这里的
+// 断言刻意都围绕"宽度"而不是"编码结果": 码 0 必须与旧行为逐字节一致、向上取档绝不开窄、
+// 非法参数必须给 0 让调用方当错误处理、超宽必须报错而不是截断。
+
+// TestFieldSizeWidthCode 宽度码 → 行内宽度。
+func TestFieldSizeWidthCode(t *testing.T) {
+	stringTypes := []FieldType{
+		FieldType_16byte, FieldType_32byte, FieldType_64byte,
+		FieldType_128byte, FieldType_256byte,
+	}
+
+	// 码 0 = "沿用类型默认宽度": v7 及以前落盘的 schema 低 5 位全为 0, 行布局必须与
+	// 改动前逐字节一致, 否则老库会被按新宽度解析 (这就是"必须能免重建"的原因)。
+	for _, ft := range stringTypes {
+		if got := FieldSize(ft, 0); got != GetSize(ft) {
+			t.Errorf("码 0 应回落到类型默认宽度: %s 得到 %d, 期望 %d",
+				TypeNameOf(ft), got, GetSize(ft))
+		}
+	}
+
+	// 每一档都要算得出宽度, 且随码单调递增
+	prev := uint16(0)
+	for c := byte(1); c <= MaxColumnWidthCode; c++ {
+		w := FieldSize(FieldType_256byte, c)
+		if w == 0 {
+			t.Fatalf("宽度码 %d 算出 0 宽度: 调用方会把它当错误, 这一档等于不可用", c)
+		}
+		if w != WidthCodeWidth(c) {
+			t.Errorf("宽度码 %d: FieldSize = %d, WidthCodeWidth = %d (两者必须一致)",
+				c, w, WidthCodeWidth(c))
+		}
+		if w <= prev {
+			t.Errorf("宽度码 %d = %d 不大于上一档 %d: 码越大必须越宽", c, w, prev)
+		}
+		prev = w
+	}
+	if prev != MaxInlineColumnWidth {
+		t.Errorf("最大档 = %d, 期望 MaxInlineColumnWidth = %d", prev, MaxInlineColumnWidth)
+	}
+
+	// 非法输入必须给 0, 不能给一个"看起来能用"的宽度
+	if w := FieldSize(FieldType_256byte, MaxColumnWidthCode+1); w != 0 {
+		t.Errorf("越界宽度码应返回 0, 得到 %d", w)
+	}
+	if w := FieldSize(FieldType(0x7e), 0); w != 0 {
+		t.Errorf("未注册类型应返回 0, 得到 %d", w)
+	}
+
+	// decimal 的列级参数是 scale, 不参与宽度: 38 位的系数区宽度固定
+	if got, want := FieldSize(FieldType_Decimal, 2), GetSize(FieldType_Decimal); got != want {
+		t.Errorf("decimal 的宽度不应随 scale 变化: 得到 %d, 期望 %d", got, want)
+	}
+	if FieldSize(FieldType_Decimal, 2) != DecimalSize {
+		t.Errorf("decimal 行宽 = %d, 期望 DecimalSize = %d",
+			FieldSize(FieldType_Decimal, 2), DecimalSize)
+	}
+}
+
+// TestWidthCodeFor 声明侧 (ORM 的 maxlen / migrate 的 SQL 长度) 取档规则: 向上取。
+//
+// 向下取会让"声明 200 字节"的列装不下 200 字节, 而上取只多占几字节 —— 这个方向性的
+// 错误在测试里看不出来 (两边都能编译), 所以在这里显式断言 w >= n。
+func TestWidthCodeFor(t *testing.T) {
+	for _, n := range []int{1, 4, 8, 12, 16, 17, 24, 32, 48, 100, 200, 256, 300, 1000, 2048} {
+		code := WidthCodeFor(n)
+		if code == 0 {
+			t.Fatalf("声明 %d 字节在行内上限 (%d) 之内, 却拿不到宽度码",
+				n, MaxInlineColumnWidth)
+		}
+		w := WidthCodeWidth(code)
+		if int(w) < n {
+			t.Errorf("声明 %d 字节拿到 %d 档 (码 %d): 取档写成了向下, 合法值会写不进去",
+				n, w, code)
+		}
+		// 2 倍宽就说明档位表太疏 (浪费行内空间), 让它成为显式失败而不是悄悄劣化
+		if int(w) > 2*n && n >= 16 {
+			t.Errorf("声明 %d 字节拿到 %d 档 (码 %d): 档位过疏, 白占行内空间",
+				n, w, code)
+		}
+	}
+
+	// 0 / 负数 = "没声明" → 码 0 (沿用类型默认)
+	if WidthCodeFor(0) != 0 || WidthCodeFor(-1) != 0 {
+		t.Errorf("未声明长度应返回码 0, 得到 %d / %d", WidthCodeFor(0), WidthCodeFor(-1))
+	}
+	// 超过行内上限 → 码 0, 由调用方决定落变长列 (而不是给一个截断的宽度)
+	if WidthCodeFor(MaxInlineColumnWidth+1) != 0 {
+		t.Errorf("超过 %d 字节应返回码 0 (调用方须改落变长列), 得到 %d",
+			MaxInlineColumnWidth, WidthCodeFor(MaxInlineColumnWidth+1))
+	}
+}
+
+// TestEncodeValueStringWidthGate 超宽必须报错, 且不许留下"写了一半"的缓冲。
+func TestEncodeValueStringWidthGate(t *testing.T) {
+	code := WidthCodeFor(32)
+	width := int(FieldSize(FieldType_256byte, code))
+
+	// 正好填满列宽: 允许
+	buf := make([]byte, width)
+	if err := EncodeValue(FieldType_256byte, code, bytes.Repeat([]byte("x"), width), buf); err != nil {
+		t.Fatalf("填满列宽 (%d 字节) 应写入成功: %v", width, err)
+	}
+
+	// 多一个字节: 必须报"超范围", 而不是静默截断
+	over := make([]byte, width)
+	err := EncodeValue(FieldType_256byte, code, bytes.Repeat([]byte("x"), width+1), over)
+	if !errors.Is(err, ErrValueRange) {
+		t.Fatalf("超宽 1 字节应报 ErrValueRange, 得到 %v", err)
+	}
+	// 失败时不能有副作用: 调用方可能复用这块缓冲 (失败后再按别的列写一次)
+	if !bytes.Equal(over, make([]byte, width)) {
+		t.Errorf("超宽报错后缓冲被改脏了: %v", over)
+	}
+
+	// 编码缓冲长度与列宽不符: 报"缓冲长度不对" —— 与"值超宽"是两种错, 不能混
+	short := make([]byte, width-1)
+	if err := EncodeValue(FieldType_256byte, code, "abc", short); !errors.Is(err, ErrEncodeBufSize) {
+		t.Errorf("缓冲长度不符应报 ErrEncodeBufSize, 得到 %v", err)
+	}
+
+	// []byte 与 string 载体都要走同一条闸门
+	if err := EncodeValue(FieldType_256byte, code, string(bytes.Repeat([]byte("y"), width+1)),
+		make([]byte, width)); !errors.Is(err, ErrValueRange) {
+		t.Errorf("string 载体超宽也应报 ErrValueRange, 得到 %v", err)
+	}
+}
+
+// TestValidateColumnParam 列级参数与类型必须自洽: 参数决定行宽, 错配是"整行错位"的入口。
+func TestValidateColumnParam(t *testing.T) {
+	// 低 5 位能表达的 scale 上限就是 MaxDecimalScale —— 两者不一致说明位布局被动过
+	if uint16(MaxDecimalScale) != uint16(FlagScaleMask) {
+		t.Errorf("MaxDecimalScale = %d, FlagScaleMask = %#x: 低 5 位能表达的 scale 上限必须等于它",
+			MaxDecimalScale, FlagScaleMask)
+	}
+
+	ok := []struct {
+		ft    FieldType
+		param byte
+	}{
+		{FieldType_Decimal, 0},
+		{FieldType_Decimal, byte(MaxDecimalScale)},
+		{FieldType_256byte, 0},                 // 类型默认档
+		{FieldType_64byte, MaxColumnWidthCode}, // 用满每一档
+		{FieldType_16byte, WidthCodeFor(12)},
+		{FieldType_8byte, 0}, {FieldType_Blob, 0}, {FieldType_Dynamicbyte, 0},
+		{FieldType_UUID, 0}, {FieldType_Int128, 0},
+	}
+	for _, c := range ok {
+		if err := ValidateColumnParam(c.ft, c.param); err != nil {
+			t.Errorf("合法组合 %s/param%d 被拒: %v", TypeNameOf(c.ft), c.param, err)
+		}
+	}
+
+	bad := []struct {
+		ft    FieldType
+		param byte
+		why   string
+	}{
+		{FieldType_256byte, MaxColumnWidthCode + 1, "宽度码越界"},
+		{FieldType_16byte, 40, "宽度码远超位宽 (会算成 0 宽度)"},
+		{FieldType_Decimal, 40, "scale 越界"},
+		{FieldType_8byte, 3, "非字符串/非 decimal 类型不该有列级参数"},
+		{FieldType_UUID, 1, "非字符串类型不该有列级参数"},
+		{FieldType_Blob, 1, "变长列不使用列级参数"},
+		{FieldType(0x7e), 1, "未注册类型"},
+	}
+	for _, c := range bad {
+		if err := ValidateColumnParam(c.ft, c.param); err == nil {
+			t.Errorf("%s: ValidateColumnParam(%s, %d) 应报错", c.why, TypeNameOf(c.ft), c.param)
+		}
+	}
+}
+
+// TestCheckRowSize 行宽闸门: 一页装不下两行就要拒, 且必须说清"哪几列占了多少"。
+//
+// 闸门卡的是【整行占用】(字段区 + 每行固定开销 + NULL 位图), 不是字段区之和 —— 这里正好
+// 钉住这个区别: 4 × 2048 = 8192 的字段区看着"正好 8K", 加上开销就装不下两行了。
+func TestCheckRowSize(t *testing.T) {
+	stringCol := func(name string, size int) FieldInfo {
+		return FieldInfo{
+			FieldType: FieldType_256byte, // 字符串家族: 行内宽度看 Flag 低 5 位的宽度码
+			Flag:      WidthCodeFor(size),
+			FieldName: []byte(name),
+		}
+	}
+	// 3 × 2048 = 6144 字段区: 加上开销仍装得下两行
+	if err := CheckRowSize([]FieldInfo{
+		stringCol("A", 2048), stringCol("B", 2048), stringCol("C", 2048),
+	}); err != nil {
+		t.Fatalf("3 × 2048 字节的字段区应当允许: %v", err)
+	}
+
+	// 4 × 2048 = 8192 字段区: "正好 8K", 却装不下两行 —— 必须拒
+	over := []FieldInfo{
+		stringCol("A", 2048), stringCol("B", 2048), stringCol("C", 2048), stringCol("D", 2048),
+	}
+	err := CheckRowSize(over)
+	if err == nil {
+		t.Fatalf("8192 字节的字段区装不下两行 (上限 %d), 必须报错", MaxRowDataSize)
+	}
+	// 只说"超了"是没法用的: 用户要知道删哪一列, 所以错误里必须有列名与各自的字节数
+	for _, want := range []string{"行宽", "A=2048", "D=2048", strconv.Itoa(MaxRowDataSize)} {
+		if !strings.Contains(err.Error(), want) {
+			t.Errorf("错误信息里缺少 %q: %v", want, err)
+		}
+	}
+
+	// 正好用满要放行, 再宽一点要拒 —— 顺带把算式本身钉死 (字段区 + 固定开销 + NULL 位图):
+	// 2048×3 + 1792 + 192 + 24 + 4 + 1 = 8157, 8 列 → 位图 1 字节, 合计正好 = 上限。
+	atLimit := []FieldInfo{
+		stringCol("A", 2048), stringCol("B", 2048), stringCol("C", 2048),
+		stringCol("D", 1792), stringCol("E", 192), stringCol("F", 24),
+		{FieldType: FieldType_4byte, FieldName: []byte("G")},
+		{FieldType: FieldType_1byte, FieldName: []byte("H")},
+	}
+	area := 0
+	for _, f := range atLimit {
+		area += int(f.Width())
+	}
+	total := area + rowFixedOverhead + NullBitmapLen(len(atLimit))
+	if total != MaxRowDataSize {
+		t.Fatalf("样本没算准: 字段区 %d, 整行占用 %d, 而上限是 %d", area, total, MaxRowDataSize)
+	}
+	if err := CheckRowSize(atLimit); err != nil {
+		t.Fatalf("正好用满 %d 字节应当允许: %v", MaxRowDataSize, err)
+	}
+	// 第 9 列既占字段区, 又让 NULL 位图从 1 字节涨到 2 字节 —— 两个加数都得算进去
+	plusOne := append(append([]FieldInfo{}, atLimit...),
+		FieldInfo{FieldType: FieldType_1byte, FieldName: []byte("I")})
+	if err := CheckRowSize(plusOne); err == nil {
+		t.Errorf("超过上限 %d 一点就必须拒 (别忘了 NULL 位图会随列数涨)",
+			MaxRowDataSize)
+	}
+
+	// 软删除的列不占行内空间 (它已不在活跃版本的 FieldIndices 里, 但 master registry 留着)
+	ghost := stringCol("Gone", 2048)
+	ghost.Flag |= FlagDeleted
+	withGhost := append(append([]FieldInfo{}, atLimit...), ghost)
+	if err := CheckRowSize(withGhost); err != nil {
+		t.Errorf("已删除的列不应计入行宽: %v", err)
+	}
+
+	// 参数非法 (宽度算成 0) 必须在建列时报错, 而不是留到写行时才越界
+	broken := []FieldInfo{{FieldType: FieldType_256byte, Flag: MaxColumnWidthCode + 1,
+		FieldName: []byte("Bad")}}
+	if err := CheckRowSize(broken); err == nil {
+		t.Errorf("宽度码非法的列必须被 CheckRowSize 拒绝")
+	}
+}
+
+// TestMaxStringSize 单值上限是"声明容量"的闸门, 与上面那条"行内宽度"上限不是一回事。
+//
+// 两个上限最容易被合成一条, 而合错的后果不是报错而是【承诺了做不到的事】: 拿 2048 去卡,
+// 就会把 varchar(60000) 这种声明静默建成一条装不下 60000 字节的列 —— 建表时通过, 某天写入
+// 某个大值时才撞 ErrHeapPageTooLarge, 用户看不出因果。所以这里钉住三点: 上限确实比行内
+// 宽度上限大得多 (它由堆页决定, 与"行内放不放得下"无关)、闸门只管上界、正好用满必须放行。
+func TestMaxStringSize(t *testing.T) {
+	// 由堆页推出 ⇒ 必然远大于 2048。若两者相等, 说明它被错写成了 MaxInlineColumnWidth,
+	// 那会让所有大字符串列都建不出来 (而正确行为是落动态列)。
+	if MaxStringSize <= MaxInlineColumnWidth {
+		t.Fatalf("单值上限 %d 必须大于行内宽度上限 %d (前者由堆页决定, 不是档位)",
+			MaxStringSize, MaxInlineColumnWidth)
+	}
+
+	// 声明多少就得能装多少: 正好用满不许拒 (动态列装得下)。
+	if err := CheckDeclaredStringSize(MaxStringSize); err != nil {
+		t.Errorf("声明 %d 字节正好用满,应当放行: %v", MaxStringSize, err)
+	}
+
+	// 多一个字节就兑现不了 —— 动态列的内容整体落在一个堆页里。
+	err := CheckDeclaredStringSize(MaxStringSize + 1)
+	if err == nil {
+		t.Fatalf("声明 %d 字节必须被拒 (动态列装不下)", MaxStringSize+1)
+	}
+	// 错误信息要能直接改代码: 既要有上限, 也要有用户写下的那个数。
+	for _, want := range []string{strconv.Itoa(MaxStringSize), strconv.Itoa(MaxStringSize + 1)} {
+		if !strings.Contains(err.Error(), want) {
+			t.Errorf("错误信息里缺少 %q: %v", want, err)
+		}
+	}
+
+	// 只判上界, 不判下界: 声明 1 字节也合法 (落最窄的 16 字节档), 不该被这里拦下。
+	if err := CheckDeclaredStringSize(1); err != nil {
+		t.Errorf("声明 1 字节应当放行: %v", err)
+	}
+}

+ 321 - 12
DB/core/types/types.go

@@ -3,6 +3,8 @@ package types
 import (
 	"fmt"
 	"math"
+	"sort"
+	"strings"
 )
 
 type FieldType byte
@@ -17,7 +19,7 @@ const (
 	FieldType_64byte      FieldType = 7  // 64字节定长字符串
 	FieldType_128byte     FieldType = 8  // 128字节定长字符串
 	FieldType_256byte     FieldType = 9  // 256字节定长字符串
-	FieldType_Dynamicbyte FieldType = 10 // 动态数据(>256字节字符串、二进制)
+	FieldType_Dynamicbyte FieldType = 10 // 动态数据(声明宽度 >MaxInlineColumnWidth 的字符串、二进制; 行内仅存 6B 堆引用)
 	// FieldType_Float64 IEEE754 双精度浮点, 8 字节。
 	//
 	// 为什么不复用 FieldType_8byte: 后者一律按整数读写, 写 123.75 会被截断成 123
@@ -117,7 +119,13 @@ const (
 // ===== FieldInfo.Flag 位布局 =====
 //
 //	bit7  bit6       bit5    | bit4 ........ bit0
-//	删除   可为 NULL   唯一    |  decimal scale (0..31)
+//	删除   可为 NULL   唯一    |  列级类型参数 (0..31)
+//
+// 低 5 位是【按类型重载】的一格, 解释方式由 FieldType 决定:
+//
+//	FieldType_Decimal                → 小数位数 scale (0..31)
+//	字符串家族 (16/32/64/128/256byte) → 行内宽度码 (0..MaxColumnWidthCode)
+//	其余类型                           → 必须为 0
 //
 // 删除标记曾经挂在 FieldType 的最高位 (0x80), 那是"类型"与"存活状态"挤在一个字节里:
 // 任何纯类型查询 (IsDynamic / GetSize / TypeNameOf) 都必须先 & 0x7F 清标记, 漏一处
@@ -150,6 +158,186 @@ const (
 	MaxColumnScale = MaxDecimalScale
 )
 
+// ===== 字符串列的行内宽度码 (Flag 低 5 位的第二种解释) =====
+//
+// 【为什么需要它】定长档位原先是"类型即宽度", 只有 16/32/64/128/256 五档, 再宽就只能
+// 落动态列 —— 而动态列【不能作为索引键】(CreateIndex 直接拒绝, 见 Table.go), 于是
+// "varchar(512) 且要唯一" 这种列在旧设计里根本表达不出来: 开 256 档会截断, 开动态列
+// 则建不了唯一索引。宽度码把行内可用宽度拉到 MaxInlineColumnWidth, 保住"可索引",
+// 代价是每行按【声明宽度】付满。
+//
+// 【为什么能塞进 Flag 低 5 位】那一格已经在用了 (decimal scale), 但 Decimal 与字符串
+// 家族互斥, 不可能同时是两者, 所以"读它的人先看类型"就没有歧义。换来的是【不新增
+// 类型值、不改变 schema 落盘格式】就让字符串列自己声明容量: ORM 的 maxlen tag 与
+// migrate 的 CHARACTER_OCTET_LENGTH 本来就有一条 byte 通道 (Column.Param → Flag) 直达
+// 这里, 原来只有 decimal 在用, 现在字符串也走同一条管子。
+//
+// 【为什么不用"新增 512/1024/2048 三个类型值"】两者行宽效果一样, 但宽度码还多两件事:
+//   - 档位可以更细 (本表 24 档, 声明 200 字节能落 224 而不是一路抬到 2048);
+//   - 一个"字符串"类型就够了, ORM/migrate 不必把"声明长度"翻译成类型枚举再翻译回来。
+//
+// 【兼容性】码 0 保留为"沿用类型默认宽度": v7 及以前落盘的 schema 低 5 位全为 0,
+// 因此那些库的行布局与改动前【逐字节一致】, 不需要重建 (详见 const.go 的 Version 注释)。
+
+// MaxInlineColumnWidth 单个字符串列可用行内宽度的上限 (字节)。
+// 声明超过它的列只能落动态列 (FieldType_Dynamicbyte / Blob, 行内 6 字节堆引用)。
+const MaxInlineColumnWidth = 2048
+
+// MaxColumnWidthCode 宽度码上限 (Flag 低 5 位能表达 0..31, 本表用到 24)。
+const MaxColumnWidthCode byte = 24
+
+// ===== 页面布局的本地镜像 (权威定义不在本包) =====
+//
+// 下面几个数只用来【推导】本包对外给出的两个存储上限, 它们本身的权威定义是:
+//
+//	refPageSize        core.PageSize
+//	refPageHdrSize     page.PageHdrSize
+//	refDataPageHdrSize page.DataPageHdrSize
+//	refHeapBlockHdr    core.dynamicBlockHdr
+//
+// 为什么要镜像: types 被 core 依赖, 不能反向 import, 拿不到那些常量。镜像不会失真 ——
+// core/Table.go 里有一组编译期断言把它们与权威值逐个钉死: 页面布局一改而这里忘了同步,
+// 直接编译失败, 而不是"上限静默失准"(那会变成允许一个写不进去的容量 / 两行装不进一页的表)。
+const (
+	refPageSize        = 16384 // 16KB 页
+	refPageHdrSize     = 20    // 公共页头
+	refDataPageHdrSize = 40    // 数据页头 = 公共页头 + 行数 + pkID 极值
+	refHeapBlockHdr    = 4     // 堆数据块的长度前缀
+)
+
+// ===== 单值的存储上限 (与上面的"行内宽度"上限是两件事) =====
+//
+// 两个上限常被混起来, 混起来就会把容量声明错:
+//   - MaxInlineColumnWidth (2048) 管"声明落在哪一档" —— 超过它的列不再定长, 转做动态列;
+//   - MaxStringSize (16360) 管"一个值最多能装多少字节" —— 动态列不按档位算容量。
+//
+// 所以"我能不能声明 varchar(60000)"要看后者, 不是前者。
+
+// MaxStringSize 单个字符串 / 二进制值可存储的字节上限 (16360)。
+//
+// 【数值来源】与 core.MaxBlobSize 同源, 都是"一个堆块必须整体落进一个堆页"这条约束的
+// 直接后果: 可用载荷 = 页大小 - 公共页头 - 块长度前缀 (下面那行就是它, 用的是布局镜像)。
+//
+// 三个加数的权威定义分别在 core 与 page; core 侧紧挨着 MaxBlobSize 有编译期断言钉住两者
+// 逐字节相等 (见 core/Table.go): 页面布局变了而这里忘了跟着改, 会直接编译失败, 而不是
+// "允许声明一个写不进去的容量"。
+//
+// 【为什么必须由声明侧来管】动态列【不保留声明长度】(Flag 低 5 位必须为 0, 见
+// ValidateColumnParam 的 default 分支), 所以过了声明侧就再也没有 N 可校验: 一句
+// `maxlen:60000` / SQL 的 varchar(60000) 会被静默建成一条装不下 60000 字节的列, 直到某天
+// 写入时才撞上 ErrHeapPageTooLarge。宁可在建表前就拒掉 (ORM), 或在迁移报告里点名
+// (migrate —— 那里不能因为一列的容量就放弃整张表的数据)。
+const MaxStringSize = refPageSize - refPageHdrSize - refHeapBlockHdr
+
+// CheckDeclaredStringSize 校验"声明的容量"是否兑现得了, 供声明侧 (ORM 的 maxlen / migrate
+// 的 SQL 长度) 调用 —— 那是唯一能看到声明值的地方, 理由见 MaxStringSize。
+//
+// 只判上界不判下界: 声明 1 字节也合法 (落到最窄的 16 字节档), 只有超宽才是"承诺了做不到的事"。
+func CheckDeclaredStringSize(size int) error {
+	if size <= MaxStringSize {
+		return nil
+	}
+	return fmt.Errorf("声明容量 %d 字节超过单值上限 %d 字节 (声明超过 %d 字节的列已落动态列, "+
+		"内容整体存在一个堆页里, 装不下更多; 请改小声明、把大列拆到附表, 或改用二进制大对象列)",
+		size, MaxStringSize, MaxInlineColumnWidth)
+}
+
+// stringWidthTable 宽度码 → 行内宽度 (字节); 码 0 是"沿用类型默认宽度", 表项恒为 0。
+//
+// 表项取 1.25 倍左右递增, 使"就近向上取码"的最坏圆整浪费不超过 25%:
+// 声明 varchar(50) 在 utf8mb4 下是 200 字节 → 取 224 档, 而不是抬到 2048。
+var stringWidthTable = [MaxColumnWidthCode + 1]uint16{
+	0, // 0: 类型默认宽度, 由 GetSize 决定 (不是本表的值)
+	16, 24, 32, 48, 64, 80, 96, 128, 160, 192, 224, 256,
+	320, 384, 448, 512, 640, 768, 896, 1024,
+	1280, 1536, 1792, 2048,
+}
+
+// IsStringType 该类型是否属于"定长字符串家族" —— 行内宽度可由 Flag 低 5 位的宽度码放大。
+//
+// FieldType_16byte 的历史注释说它兼作"128 位整数": ORM 现在把 int128 收编到
+// FieldType_Int128, 因此这里把 16byte 一律当字符串看待; 真拿它当裸 16 字节用的列,
+// 宽度码保持 0 即与旧行为逐字节一致。
+func IsStringType(ft FieldType) bool {
+	switch ft {
+	case FieldType_16byte, FieldType_32byte, FieldType_64byte,
+		FieldType_128byte, FieldType_256byte:
+		return true
+	}
+	return false
+}
+
+// WidthCodeFor 返回"能容纳 size 字节"的最小宽度码 (size <= 0 时给 0 = 类型默认)。
+//
+// 声明侧 (ORM 的 maxlen / migrate 的 SQL 长度) 走这里: 容量不够就向上取, 绝不向下 ——
+// 向下取会让合法值写不进去, 而上取只多占几字节。
+func WidthCodeFor(size int) byte {
+	if size <= 0 {
+		return 0
+	}
+	for c := byte(1); c <= MaxColumnWidthCode; c++ {
+		if int(stringWidthTable[c]) >= size {
+			return c
+		}
+	}
+	// 超过 MaxInlineColumnWidth: 调用方改落动态列。注意这不等于"声明多大都行" ——
+	// 动态列本身还有单值上限 MaxStringSize, 声明侧必须另过 CheckDeclaredStringSize。
+	return 0
+}
+
+// WidthCodeWidth 宽度码对应的宽度 (调试/回显用); 码 0 返回 0。
+func WidthCodeWidth(code byte) uint16 {
+	if code > MaxColumnWidthCode {
+		return 0
+	}
+	return stringWidthTable[code]
+}
+
+// FieldSize 一列在行内的实际宽度 (字节)。
+//
+// param 取 FieldInfo.Flag 低 5 位 (见 FieldInfo.Param): Decimal 读作 scale (不影响宽度),
+// 字符串家族读作宽度码, 其余类型必须为 0。
+//
+// 返回 0 表示"算不出宽度" (未注册类型 / 非法宽度码)。调用方必须把它当错误处理: 0 宽度
+// 会让 CalcRowSize 少算字节, 其后的每一列都从错位偏移解码 —— 这正是本包注释反复警告的
+// "整行字段静默错位"。
+func FieldSize(ft FieldType, param byte) uint16 {
+	if !IsStringType(ft) {
+		return GetSize(ft)
+	}
+	if param == 0 {
+		return GetSize(ft) // 类型默认宽度
+	}
+	if param > MaxColumnWidthCode {
+		return 0
+	}
+	return stringWidthTable[param]
+}
+
+// ValidateColumnParam 校验列级参数与类型是否自洽。
+//
+// 建列 (CreateTable / AddField) 与读 schema 时都要过一遍: 一个挂错类型的残留参数会改变
+// 行宽, 而改行宽是"整行字段错位"的唯一入口。
+func ValidateColumnParam(ft FieldType, param byte) error {
+	switch {
+	case ft == FieldType_Decimal:
+		if int(param) > MaxDecimalScale {
+			return fmt.Errorf("decimal scale=%d 超过上限 %d", param, MaxDecimalScale)
+		}
+	case IsStringType(ft):
+		if param > MaxColumnWidthCode {
+			return fmt.Errorf("字符串列宽度码=%d 超过上限 %d (最大行内宽度 %d 字节)",
+				param, MaxColumnWidthCode, MaxInlineColumnWidth)
+		}
+	default:
+		if param != 0 {
+			return fmt.Errorf("类型 %s 不使用列级参数, 但 Flag 低 5 位为 %d",
+				TypeNameOf(ft), param)
+		}
+	}
+	return nil
+}
+
 // Int128Size 128 位整数的存储宽度 (Int128 / Decimal 的系数部分)。
 const Int128Size = 16
 
@@ -254,8 +442,6 @@ func SortKeyToFloat32(k uint32) float32 {
 	return math.Float32frombits(k)
 }
 
-const MaxInlineStringSize = 256 // 定长字符串最大长度,超过则用堆页
-
 // DynamicRefSize 动态字段在行内的引用大小
 // 布局: [PageID uint32 BE(4B)][Offset uint16 BE(2B)]
 // PageID == 0 表示空值; 真实数据存放在堆页 Offset 处
@@ -282,14 +468,15 @@ type FieldInfo struct {
 	// Flag 列级标志 (随 schema 落盘, 缺省 0)。位布局见上面的 Flag* 常量:
 	//
 	//	bit7  bit6       bit5    | bit4 ........ bit0
-	//	删除   可为 NULL   唯一    |  decimal scale (0..31)
+	//	删除   可为 NULL   唯一    |  列级类型参数 (0..31)
 	//
 	//   - FlagDeleted:  已被 DropField 软删除 (master registry 永不真正移除,
 	//     旧 schema 版本仍需用它定位行内偏移);
 	//   - FlagNullable: 该列允许 NULL (行内 NULL 位图据此置位, 见 NullBitmapLen);
 	//   - FlagUnique:   该列声明了唯一约束 (NULL 不参与; 约束由唯一索引执行,
 	//     见 FlagUnique 的说明);
-	//   - 低 5 位:      FieldType_Decimal 的列级 scale (0..MaxDecimalScale)。
+	//   - 低 5 位:      列级类型参数, 【按类型解释】—— Decimal 是 scale,
+	//     字符串家族是行内宽度码 (见 FieldSize)。读宽度一律经 Param()/Width()。
 	//
 	// 紧跟在 FieldType 之后落盘: 两者必须一起读、一起改。
 	Flag byte
@@ -318,6 +505,16 @@ func (f FieldInfo) IsUnique() bool { return f.Flag&FlagUnique != 0 }
 // Scale 返回本列的小数位数 (仅对 FieldType_Decimal 有意义)。
 func (f FieldInfo) Scale() uint16 { return uint16(f.Flag & FlagScaleMask) }
 
+// Param 返回列级类型参数 (Flag 低 5 位): Decimal 读作 scale, 字符串家族读作宽度码,
+// 其余类型恒为 0 (由 ValidateColumnParam 保证)。
+//
+// 它是"宽度怎么算"的唯一入口 —— 所有行偏移计算都必须经 Width()/FieldSize 取宽度,
+// 不许直接 GetSize(FieldType): 后者只知道类型默认宽度, 对使用宽度码的列会算窄。
+func (f FieldInfo) Param() byte { return f.Flag & FlagScaleMask }
+
+// Width 本列的行内宽度 (字节), 见 FieldSize。0 表示参数非法 (调用方须当错误处理)。
+func (f FieldInfo) Width() uint16 { return FieldSize(f.FieldType, f.Param()) }
+
 // ColumnScale 返回某字段的列级 scale (Decimal 的小数位数; 其余类型为 0)。
 //
 // 查询侧 (compareValue / encodeIndexKey) 需要它才能把条件值编码成与行内完全一致的
@@ -422,21 +619,129 @@ func CheckRegistry() error {
 			return fmt.Errorf("字段类型 %d 未注册名字", t)
 		}
 	}
+	// 宽度码表自检: 它直接决定字符串列的行宽, 一旦不是严格递增或最大值对不上常量,
+	// 后果是"声明 2048 却拿到更窄的宽度" → 写入被截断 (或报出看不懂的越界)。
+	prev := uint16(0)
+	for c := byte(1); c <= MaxColumnWidthCode; c++ {
+		w := stringWidthTable[c]
+		if w == 0 {
+			return fmt.Errorf("宽度码 %d 的表项为 0: 会让该列宽度算成 0, 整行字段错位", c)
+		}
+		if w <= prev {
+			return fmt.Errorf("宽度码表不是严格递增: 码 %d = %d 不大于上一档 %d", c, w, prev)
+		}
+		if w > MaxInlineColumnWidth {
+			return fmt.Errorf("宽度码 %d = %d 超过行内上限 %d", c, w, MaxInlineColumnWidth)
+		}
+		prev = w
+	}
+	if prev != MaxInlineColumnWidth {
+		return fmt.Errorf("宽度码表最大值 %d 与 MaxInlineColumnWidth %d 不一致",
+			prev, MaxInlineColumnWidth)
+	}
+	if stringWidthTable[0] != 0 {
+		return fmt.Errorf("宽度码 0 必须是「沿用类型默认宽度」, 表项应为 0, 实际 %d", stringWidthTable[0])
+	}
 	return nil
 }
 
+// CalcRowSize 求和一行字段区的字节数。
+//
+// 宽度必须走 Width() 而不是 GetSize(FieldType): 使用宽度码的字符串列, 其行内宽度
+// 由列自己声明 (最大 MaxInlineColumnWidth), 只看类型会算成 256 并让整行偏移全错。
 func CalcRowSize(fields []FieldInfo) uint16 {
 	var size uint16 = 0
-	for _, ft := range fields {
-		if IsDynamic(ft.FieldType) {
-			size += DynamicRefSize
-		} else {
-			size += GetSize(ft.FieldType)
-		}
+	for _, f := range fields {
+		size += f.Width()
 	}
 	return size
 }
 
+// ===== 单行在数据页里的占用上限 (由"一页至少两行"推出) =====
+
+// rowFixedOverhead 每行在 DataPage 里的固定开销 (字节, 不含 NULL 位图与字段区):
+//
+//	4  行长度前缀 —— 每行前面写一个 len (行数据区从 DataPage 头之后开始)
+//	8  pkID —— 行主键号, 也就是行存储索引的键 (时序行同样是 [pkID][schemaVer][字段…])
+//	1  schemaVer —— 该行按哪一版 schema 落盘, 读回时靠它解释字段区
+//
+// 两项不在这里:
+//   - NULL 位图在行尾, 长度随字段数变化 (见 NullBitmapLen);
+//   - 主键列 / 时序键是 schema 里的【真实列】, 由 CheckRowSize 按列宽算进字段区 ——
+//     主键表的 _id 列是 pkID 的副本, 时序表的时间键必须落在首列 (时序索引按固定偏移 9
+//     = pkID 8B + schemaVer 1B 读它)。所以不要在这里为时序表再加一次 8 字节。
+const rowFixedOverhead = 4 + 8 + 1
+
+// MaxRowDataSize 单行在 DataPage 里的【总占用】上限 = 8171 字节。
+//
+// 【数值来源】它不是拍出来的 8KB, 而是"一页至少装得下两行"这条设计政策反推出来的:
+//
+//	2 × 每行占用 ≤ 页大小 - 数据页头
+//	每行占用 = 行长度前缀 + pkID + schemaVer + NULL 位图 + 字段区
+//
+// 末了减 1 是引擎的装填判定留的余量: 插入一行时按 "本行末尾 + 本行长度 ≥ 页尾" 提前判失败
+// (见 core/index/datapage 的 InsertRowSortedByOffset), 页内最后一字节实际用不上。
+//
+// 【为什么要求两行】一行占满一页会让页内顺序扫描、按 pkID 的有序插入、更新时的页内重建
+// 全部退化, 而且"一行占满一页"意味着任何一次字段变宽都要换页。两行是这条政策的底线。
+//
+// 【为什么不是 8192】8K 是个好看的数, 却【装不下两行】: 每行除字段区还有固定开销与 NULL
+// 位图, 两行合计会越过页里能用的 16344 字节 —— 写满 8K 字段区的表实际只能一行一页, 正是
+// 这条政策要避免的情形。所以闸门卡的是【整行占用】而不是字段区之和, 见 CheckRowSize。
+//
+// 【为什么在建列时校验】行宽是 schema 属性, 不是行数据属性: 一张建出来就超限的表,
+// 之后每一行都会写入失败。在 CreateTable / AddField 就拒绝, 用户才知道要砍哪一列。
+const MaxRowDataSize = (refPageSize - refDataPageHdrSize - 1) / 2
+
+// CheckRowSize 校验一张表的行是否装得下两行。
+//
+// 卡的是【整行占用】(字段区 + 每行固定开销 + NULL 位图), 不是字段区之和 —— 只数字段区会
+// 把开销漏掉, 于是"闸门内"的表实际两行装不进一页 (旧上限 8192 正是如此)。
+//
+// 超限时把"各列占了多少"按降序列出来 —— 只说"超了"没法用, 用户要知道删哪一列。
+func CheckRowSize(fields []FieldInfo) error {
+	type colSize struct {
+		name string
+		size int
+	}
+	cols := make([]colSize, 0, len(fields))
+	area, live := 0, 0
+	for _, f := range fields {
+		if f.IsDropped() {
+			continue // 软删除的列不占行内空间
+		}
+		w := int(f.Width())
+		if w == 0 {
+			return fmt.Errorf("列 %q 的宽度不可计算 (类型 %s, 列级参数 %d 非法)",
+				f.FieldName, TypeNameOf(f.FieldType), f.Param())
+		}
+		area += w
+		live++
+		cols = append(cols, colSize{string(f.FieldName), w})
+	}
+	bitmap := NullBitmapLen(live)
+	total := area + rowFixedOverhead + bitmap
+	if total <= MaxRowDataSize {
+		return nil
+	}
+	sort.Slice(cols, func(i, j int) bool { return cols[i].size > cols[j].size })
+	var b strings.Builder
+	for i, c := range cols {
+		if i == 10 {
+			fmt.Fprintf(&b, ", …(其余 %d 列略)", len(cols)-i)
+			break
+		}
+		if i > 0 {
+			b.WriteString(", ")
+		}
+		fmt.Fprintf(&b, "%s=%d", c.name, c.size)
+	}
+	return fmt.Errorf("行宽 %d 字节 (字段区 %d + 每行固定开销 %d + NULL 位图 %d) 超过上限 %d 字节 "+
+		"(上限 = 一页装下两行, 见 MaxRowDataSize); 按列宽降序: %s; "+
+		"请把宽度超过 %d 字节的列改为动态列, 或把大列拆到附表",
+		total, area, rowFixedOverhead, bitmap, MaxRowDataSize, b.String(), MaxInlineColumnWidth)
+}
+
 func IsDynamic(ft FieldType) bool {
 	if info, ok := typeRegistry[ft]; ok {
 		return info.IsDynamic
@@ -444,6 +749,10 @@ func IsDynamic(ft FieldType) bool {
 	return false
 }
 
+// GetSize 返回【类型默认】的行内宽度 —— 对使用宽度码的字符串列, 这不是该列的真实宽度。
+//
+// 计算行偏移/行宽必须用 FieldSize(ft, param) 或 FieldInfo.Width(); 只有"要的就是该类型
+// 的原生档位"时才用本函数 (如 sqlTypeName 回显默认档、宽度码缺省值 0 的解析)。
 func GetSize(ft FieldType) uint16 {
 	if info, ok := typeRegistry[ft]; ok {
 		return info.Size

+ 57 - 35
DB/core/wal.go

@@ -72,12 +72,11 @@ type RowEntry struct {
 
 // WALWriter 事务 WAL 写入器
 type WALWriter struct {
-	txnID    uint64
-	entries  []walEntry
-	file     *File
-	header   *HeaderPage
-	readPage func(uint32, []byte) error // 用于分配页时读取空闲链表
-	state    byte                       // 当前 WAL 状态 (Mem/Written/Persisted/Committed)
+	txnID   uint64
+	entries []walEntry
+	file    *File
+	header  *HeaderPage
+	state   byte // 当前 WAL 状态 (Mem/Written/Persisted/Committed)
 }
 
 type walEntry struct {
@@ -90,15 +89,18 @@ type walEntry struct {
 
 // BeginTxn 开始新事务
 func (db *DB) BeginTxn() *WALWriter {
-	txnID := db.header.NextTxnID
-	db.header.NextTxnID++
+	// NextTxnID 是头页里的全局游标, 提交 / 事务提交都可能并发取号, 必须在 headerMu 内自增。
+	var txnID uint64
+	db.file.withHeader(func() {
+		txnID = db.header.NextTxnID
+		db.header.NextTxnID++
+	})
 	return &WALWriter{
-		txnID:    txnID,
-		entries:  make([]walEntry, 0, 16),
-		file:     db.file,
-		header:   db.header,
-		readPage: db.file.readPageDisk, // 必须直接读盘! 走 BufferPool 会被旧缓存干扰空闲链表
-		state:    WALStateMem,          // 初始状态: Mem (内存中)
+		txnID:   txnID,
+		entries: make([]walEntry, 0, 16),
+		file:    db.file,
+		header:  db.header,
+		state:   WALStateMem, // 初始状态: Mem (内存中)
 	}
 }
 
@@ -155,6 +157,10 @@ func (w *WALWriter) Rollback() {
 // ⭐ 关键: 「提交标记」必须先于「回写真实页」落盘。
 // 若顺序反过来, 断电落在两者之间时真实页已被原地覆盖, 而提交标记仍是旧值,
 // 恢复会丢弃整个 WAL, 留下半应用(损坏)的页 —— 这里没有 undo, 无法自愈。
+//
+// 缓冲池的脏标记【不在这里】收尾: 提交方拿到 success 之后调用 File.MarkClean
+// (按版本号逐页判定), 因为只有提交方知道哪些页属于这次快照, 也才知道窗口里有没有
+// 人把这些页又改了一遍。
 func (w *WALWriter) Commit() error {
 	if len(w.entries) == 0 {
 		return nil
@@ -184,28 +190,41 @@ func (w *WALWriter) Commit() error {
 
 	// === 步骤 4: 回写实际页 (直接写盘, 绕过 BufferPool) ===
 	// 放在提交标记之后: 中途断电可由恢复阶段重做 (整页镜像, 幂等)。
+	//
+	// 用 WritePageHome 而不是 WritePageDirect: 这里写出去的正是缓存快照本身, 缓存里
+	// 那一份的内容只会等于它或新于它 (并发写入时), 无差别失效会丢掉更新的那一份。
+	// 缓存状态由提交方在 Commit() 返回后用 File.MarkClean 按版本号收尾 —— 这也是
+	// 这里不再 ClearCache 的原因: ClearAll 会把提交窗口里被重新改脏的页一并标干净,
+	// 那些页并不在本笔 WAL 里, 标干净等于把它们的新内容永久丢掉。
 	for _, e := range w.entries {
 		if e.pageID == 0 {
 			continue
 		}
-		if err := w.file.WritePageDirect(e.pageID, e.data); err != nil {
+		if err := w.file.WritePageHome(e.pageID, e.data); err != nil {
 			return err
 		}
 	}
-	w.file.ClearCache()
 
 	// === 步骤 5: 回收整条 WAL 链 (O(1) 拼接, 与链长无关) ===
 	// 链内每页的 next 都写在 PageHdr.NextPage, 与空闲链同字段, 所以整条链
 	// head…tail 可以直接挂到空闲链尾, 不必逐页遍历回收。
 	// 链首/链尾/链长都已在 writeWALPages 中记录 (walPageIDs), 尾页 NextPage 必为 0。
-	if err := recycleChainWithCache(w.file, w.header,
-		walPageIDs[0], walPageIDs[len(walPageIDs)-1], uint32(len(walPageIDs))); err != nil {
-		return err
-	}
-	w.header.FirstWALPage = 0
-	w.header.LastWALPage = 0
-	if err := w.file.WriteHeader(w.header); err != nil {
-		return err
+	//
+	// 还链 (改空闲链指针) 与清空 WAL 链指针是一次头页上的复合修改, 必须整体落在
+	// headerMu 内, 否则并发写入线程可能看到"WAL 链已清空、空闲链却还没接好"的中间态。
+	var recycleErr error
+	w.file.withHeader(func() {
+		recycleErr = recycleChainWithCache(w.file, w.header,
+			walPageIDs[0], walPageIDs[len(walPageIDs)-1], uint32(len(walPageIDs)))
+		if recycleErr != nil {
+			return
+		}
+		w.header.FirstWALPage = 0
+		w.header.LastWALPage = 0
+		recycleErr = w.file.writeHeaderLocked(w.header)
+	})
+	if recycleErr != nil {
+		return recycleErr
 	}
 	// 收尾必须 fsync: 否则断电后磁盘上的 Header 仍指向已提交的 WAL,
 	// 下次打开会重复重放 (行级记录场景还会造成重复插入)
@@ -256,12 +275,11 @@ func (w *WALWriter) writeWALPages() ([]uint32, error) {
 
 		// 当前页放不下 → 开新页
 		if curBuf == nil || uint32(PageSize)-curOff < need {
-			// 分配新页 (必须用 newPageWithReader 推进空闲链表)。
-			// writePage 传 nil (不擦页): WAL 页紧接着就在内存里被整页重造 (curBuf 是
-			// 全 0 的新切片, flushCurrent 一次写满 16KB), 而且链指针是最后才经
-			// header.FirstWALPage/LastWALPage 发布的 —— 崩在中间时恢复读到的头页仍指向
-			// 上一轮那条已写完整的链, 这张还没写过的页根本不可达。
-			pid := w.header.newPageWithReader(w.readPage, nil)
+			// 分配新页: headerMu 内摘空闲链 / 递增游标并推进头页。
+			// erase=false —— 页紧接着就在内存里被整页重造 (curBuf 是全 0 的新切片,
+			// flushCurrent 一次写满 16KB), 不需要擦页; 且此刻正处在 Commit 内
+			// (持有 commitMu), 擦页会重入 autoCommit → Commit → 自死锁在 commitMu 上。
+			pid := w.file.allocPage(w.header, false)
 
 			// 先回填上一页的链指针, 再把上一页定型落盘。
 			// 链指针统一写 PageHdr.NextPage (偏移 12) —— 与空闲链表同一个字段,
@@ -319,12 +337,16 @@ func (w *WALWriter) writeWALPages() ([]uint32, error) {
 	}
 
 	// 更新 HeaderPage 的 WAL 链指针
+	// 发布链指针是为了崩溃恢复能找回这笔事务, 必须在 headerMu 内对"首页是否已存在"
+	// 与"尾页指向"做一次原子更新, 否则并发提交可能互相覆盖链尾。
 	if len(pageIDs) > 0 {
-		if w.header.FirstWALPage == 0 {
-			w.header.FirstWALPage = pageIDs[0]
-		}
-		w.header.LastWALPage = pageIDs[len(pageIDs)-1]
-		_ = w.file.WriteHeader(w.header)
+		w.file.withHeader(func() {
+			if w.header.FirstWALPage == 0 {
+				w.header.FirstWALPage = pageIDs[0]
+			}
+			w.header.LastWALPage = pageIDs[len(pageIDs)-1]
+			_ = w.file.writeHeaderLocked(w.header)
+		})
 	}
 
 	// 状态转换: Mem → Written (已写盘,未 fsync)

+ 4 - 2
DB/core/wal_recovery.go

@@ -188,8 +188,10 @@ func RecoverWAL(ff *File, header *HeaderPage) error {
 		}
 	}
 
-	header.FirstWALPage = 0
-	header.LastWALPage = 0
+	ff.withHeader(func() {
+		header.FirstWALPage = 0
+		header.LastWALPage = 0
+	})
 	if err := ff.WriteHeader(header); err != nil {
 		return err
 	}

+ 37 - 11
DB/db.go

@@ -292,9 +292,13 @@ func isPKColumn(model *ModelInfo, name string) bool {
 // syncSchema 将 DB schema 同步为与 struct 一致
 // struct 多 → AddField;struct 少 → DropField
 //
-// 除了增删列, 还会校验【已存在列的 decimal scale】: 同一列的 scale 必须一致才能保证
-// "行内字节序 == 数值序" (见 types.FieldType_Decimal)。模型和盘上对不上时直接报错,
-// 而不是让新写入的行用另一套 scale —— 那会让同一列里两种 scale 混排, 排序静默出错。
+// 除了增删列, 还会校验【已存在列的列级参数】: decimal 的 scale 与字符串列的宽度码都必须
+// 与模型一致。两者都是"行布局参数":
+//   - scale 不一致 → "行内字节序 == 数值序"不再成立 (见 types.FieldType_Decimal), 排序静默出错;
+//   - 宽度不一致 → 模型以为的容量与库里的实际容量不同 (闸门只认库里的档位), 声明 maxlen:16
+//     的列实际能写满 1024 字节, 或反之合法值被拒。
+//
+// 对不上时直接报错, 而不是让新写入的行用另一套参数 —— 那会让同一列里两种布局混排。
 func syncSchema(coreTbl *core.Table, model *ModelInfo) error {
 	// 构建 struct 字段名集合 (不含主键)
 	structCols := make(map[string]FieldDef, len(model.Fields))
@@ -332,17 +336,39 @@ func syncSchema(coreTbl *core.Table, model *ModelInfo) error {
 			}
 			continue
 		}
-		// 已存在的列: 校验 decimal 的列级 scale 是否与模型一致。
-		if fd.FieldType == types.FieldType_Decimal {
-			dbFT, _ := coreTbl.GetActiveFieldType(fd.ColName)
-			dbFlag, _ := coreTbl.GetActiveFieldFlag(fd.ColName)
-			dbScale := dbFlag & types.FlagScaleMask
-			modelScale := fd.Flag & types.FlagScaleMask
-			if dbFT == types.FieldType_Decimal && dbScale != modelScale {
+		// 已存在的列: 校验列级参数 (decimal 的 scale / 字符串列的宽度码) 是否与模型一致。
+		dbFT, _ := coreTbl.GetActiveFieldType(fd.ColName)
+		dbFlag, _ := coreTbl.GetActiveFieldFlag(fd.ColName)
+		dbParam := dbFlag & types.FlagScaleMask
+
+		if fd.FieldType == types.FieldType_Decimal || dbFT == types.FieldType_Decimal {
+			if fd.FieldType == types.FieldType_Decimal && dbFT == types.FieldType_Decimal &&
+				dbParam != fd.Param() {
 				return fmt.Errorf(
 					"vrtx: 列 %s 的 decimal scale 与库中不一致 (库中 %d, 模型 %d); "+
 						"同一列只能有一个 scale, 请对齐模型或迁移/重建该列",
-					fd.ColName, dbScale, modelScale)
+					fd.ColName, dbParam, fd.Param())
+			}
+			continue
+		}
+
+		// 字符串列 (含动态列): 行内宽度必须与模型声明一致。
+		//
+		// 宽度决定"这一列占行内哪几字节", 库里的档位就是真正的闸门 (见 types.FieldSize):
+		// 库里是 1024 字节档而模型声明 16 字节档时, 模型以为"最多 16 字节"的列其实能写满
+		// 1024; 反过来库里的档位更窄, 模型认为合法的值会被引擎拒。两种都不会自己暴露,
+		// 所以在这里一次性说清库中与模型各自的宽度和宽度码。
+		if types.IsStringType(fd.FieldType) || types.IsStringType(dbFT) ||
+			types.IsDynamic(fd.FieldType) || types.IsDynamic(dbFT) {
+			dbW := types.FieldSize(dbFT, dbParam)
+			modelW := fd.Width()
+			if dbW != modelW {
+				return fmt.Errorf(
+					"vrtx: 列 %s 的行内宽度与库中不一致 (库中 %s: %d 字节/宽度码 %d, "+
+						"模型 %s: %d 字节/宽度码 %d); 定宽随 schema 落盘, 对不上说明模型与库"+
+						"是两次不同的声明 — 请对齐 maxlen/type, 或重建该列",
+					fd.ColName, types.TypeNameOf(dbFT), dbW, dbParam,
+					types.TypeNameOf(fd.FieldType), modelW, fd.Param())
 			}
 		}
 	}

+ 6 - 4
DB/query.go

@@ -210,10 +210,12 @@ func normalizeCondValue(v interface{}, ft types.FieldType) interface{} {
 		types.FieldType_Blob:
 		return v
 	default:
-		// 定长字符串类型: 转换为 []byte (core 的 encodeIndexKey 需要)
-		if s, ok := v.(string); ok {
-			return toFixedBytes(s, int(types.GetSize(ft)))
-		}
+		// 定长字符串类型: 原样下传, 由 core 按【列的实际宽度】编码
+		// (types.EncodeValue 接受 string, 并顺带成为宽度闸门: 超宽报错而不是截断)。
+		//
+		// 不能在这里按 types.GetSize(ft) 自己补零: 那只是类型默认宽度, 对用宽度码声明的
+		// 列 (maxlen:1000 → 1024 字节档) 会编出 256 字节——与行内字节不同源, 比较与索引键
+		// 全错。而且这里只有 ft 没有列级参数, 本就拼不出正确的宽度。
 		return v
 	}
 }

+ 72 - 13
DB/types.go

@@ -18,20 +18,34 @@ type FieldDef struct {
 	ColName   string          // 数据库列名 (来自 vrtx tag 或字段名)
 	FieldType types.FieldType // 存储类型
 	// Flag 列级标志 (随 schema 落盘, 直接透传给 types.FieldInfo.Flag):
-	//   - 低 5 位:    FieldType_Decimal 的小数位数 scale (0..types.MaxDecimalScale),
+	//   - 低 5 位:    列级类型参数【按类型解释】——
+	//                 FieldType_Decimal 是小数位数 scale (0..types.MaxDecimalScale),
 	//                 来自 `vrtx:"decimal:2"` / `vrtx:"scale:2"`;
+	//                 字符串家族 (16/32/64/128/256byte) 是行内宽度码, 来自
+	//                 `vrtx:"maxlen:N"` (见 maxlenToColumn; N 的上界是
+	//                 types.MaxStringSize, 超了在建模阶段就报错);
 	//   - 状态位:     types.FlagNullable (来自 `vrtx:"nullable"`);
 	//                 types.FlagUnique   (来自 `vrtx:"unique"`, 唯一约束; NULL 不参与);
 	//                 types.FlagDeleted 由核心层 DropField 置位, 声明侧不设。
 	//
-	// Decimal 的保序前提是"同一列 scale 一致", 所以 scale 必须作为列元数据存下来,
-	// 而不能只活在 Go 类型里 (见 types.FieldType_Decimal)。
+	// Decimal 的保序前提是"同一列 scale 一致", 字符串列的定宽前提是"同一列宽度码
+	// 一致" —— 两者都必须作为列元数据存下来, 而不能只活在 Go 类型里 (见 types.FieldSize)。
 	Flag   byte
 	GoKind reflect.Kind // Go 原始类型
 	Index  int          // 该字段在 AllFields 中的位置
 	IsPK   bool         // 用户声明的主键字段 (vrtx:"pk" / 显式列名 _id)
 }
 
+// Param 列级类型参数 (Flag 低 5 位): Decimal 读作 scale, 字符串家族读作宽度码。
+func (fd FieldDef) Param() byte { return fd.Flag & types.FlagScaleMask }
+
+// Width 该列在行内的实际宽度 (字节)。
+//
+// 不能写成 types.GetSize(fd.FieldType): 那只是【类型默认】宽度, 对 `maxlen:1000`
+// 这种用宽度码声明出来的列会算成 256 —— ORM 侧任何按这个宽度补零/截断的地方, 都会
+// 与引擎按 1024 存下来的字节不同源。宽度只认这一处 (转发给 types.FieldSize)。
+func (fd FieldDef) Width() uint16 { return types.FieldSize(fd.FieldType, fd.Param()) }
+
 // IndexDef 从结构体标签解析的二级索引定义
 type IndexDef struct {
 	Name      string         // 索引名 (空=自动生成 idx_表名_首个字段名)
@@ -559,6 +573,9 @@ func parseField(sf reflect.StructField, idx int) (FieldDef, *IndexDef, error) {
 					fd.Flag = fd.Flag&^types.FlagScaleMask | byte(n)
 					scaleSet = true
 				case "maxlen":
+					// maxlen 的单位是【字节】(与 types.FieldSize 同口径), 不是字符数:
+					// 引擎的行布局按字节算, 拿字符数去声明会让 utf8mb4 中文列 (3 字节/字)
+					// 实际容量只有声明的 1/3。需要按字符数声明时请乘上编码宽度。
 					if n, err := strconv.Atoi(val); err == nil && n > 0 {
 						maxlen = n
 					}
@@ -613,10 +630,21 @@ func parseField(sf reflect.StructField, idx int) (FieldDef, *IndexDef, error) {
 			fd.Flag &^= types.FlagUnique
 		}
 
-		// 处理 maxlen: 只在没有显式列类型时生效 (显式声明优先)
+		// 处理 maxlen: 只在没有显式列类型时生效 (显式列类型优先)
 		if maxlen > 0 && !typeSet {
 			if sf.Type.Kind() == reflect.String {
-				fd.FieldType = maxlenToFieldType(maxlen)
+				ft, code, err := maxlenToColumn(maxlen)
+				if err != nil {
+					// 声明了引擎兑现不了的容量: 在 ParseModel 阶段就拒, 不留"表建好了、
+					// 某天写入才失败"的半成品 (与 validateFieldTypeParam 同一原则)。
+					return fd, nil, fmt.Errorf("字段 %s: %w", fd.ColName, err)
+				}
+				fd.FieldType = ft
+				// 宽度码写进 Flag 低 5 位: 它就是这一列的定宽依据, 随 schema 落盘,
+				// 与 decimal 的 scale 共用同一格 (按类型解释, 见 types.FieldSize)。
+				if code > 0 {
+					fd.Flag = fd.Flag&^types.FlagScaleMask | code
+				}
 			}
 			// 数值类型忽略 maxlen (用 Go 类型自动推断)
 		}
@@ -785,19 +813,50 @@ func int128Carrier(t reflect.Type) bool {
 	return false
 }
 
-// maxlenToFieldType 将 maxlen 值映射到最近的 FieldType
-func maxlenToFieldType(maxlen int) types.FieldType {
+// maxlenToColumn 把 `vrtx:"maxlen:N"` (单位: 字节) 映射成 (列类型, 列级宽度码)。
+//
+// 返回的 code 写进 FieldInfo.Flag 低 5 位, 由引擎按它决定行内宽度 (见 types.FieldSize)。
+// code == 0 表示"落动态列"。
+//
+// 【语义: 向上取到最近的档位】宽度码只有 24 档 (16,24,32,...,2048), 声明 200 字节能拿到
+// 224 字节档, 声明 10 只能拿到 16 字节档 —— 宁可贵几字节也不能向下取, 向下取会让合法值
+// 写不进去。真正的闸门是【档位宽度】(写入超过档位宽度会报错并报出该列容量), 不是 N 本身。
+//
+// 超过 types.MaxInlineColumnWidth (2048) 的声明退回动态列 (行内 6 字节堆引用):
+// 定长档位的存在意义是"这一列可索引、可定长比较", 而动态列两者都没有 (CreateIndex 会
+// 明确拒绝), 所以 2048 是"要索引就别超过它"的分界线。
+//
+// 【N 还有第二道上界】落动态列不等于"声明多大都行": 动态列的内容整体落在一个堆页里,
+// 单值上限是 types.MaxStringSize。声明超过它的列永远兑现不了, 所以这里直接报错, 而不是
+// 悄悄建成一条装不下那么多字节的列。这道闸门只能在这里 —— 动态列【不保留声明长度】
+// (Flag 低 5 位必须为 0), 一旦过了这层, N 就不在盘上了, 事后再无据可查。
+func maxlenToColumn(maxlen int) (types.FieldType, byte, error) {
+	if err := types.CheckDeclaredStringSize(maxlen); err != nil {
+		return 0, 0, err
+	}
+	code := types.WidthCodeFor(maxlen)
+	if code == 0 {
+		return types.FieldType_Dynamicbyte, 0, nil
+	}
+	return baseStringTypeFor(maxlen), code, nil
+}
+
+// baseStringTypeFor 选一个"类型名不离谱"的定长字符串基底类型。
+//
+// 字符串家族的行内宽度【统一由宽度码决定】, 基底类型只影响回显 (sqlTypeName / 类型名),
+// 所以这里取"默认宽度不超过声明值"的最大档: `maxlen:200` 显示成 256byte 而不是 16byte。
+func baseStringTypeFor(size int) types.FieldType {
 	switch {
-	case maxlen <= 32:
+	case size <= 16:
+		return types.FieldType_16byte
+	case size <= 32:
 		return types.FieldType_32byte
-	case maxlen <= 64:
+	case size <= 64:
 		return types.FieldType_64byte
-	case maxlen <= 128:
+	case size <= 128:
 		return types.FieldType_128byte
-	case maxlen <= 256:
-		return types.FieldType_256byte
 	default:
-		return types.FieldType_Dynamicbyte
+		return types.FieldType_256byte
 	}
 }
 

+ 87 - 0
DB/types_test.go

@@ -0,0 +1,87 @@
+package DB
+
+import (
+	"strconv"
+	"strings"
+	"testing"
+
+	"TsDB2/DB/core/types"
+)
+
+// TestParseModelMaxlenCap `maxlen` 声明超过单值上限的字符串列必须在建模阶段就被拒。
+//
+// 这一层是唯一能拦它的地方: 声明超过 MaxInlineColumnWidth 的列落动态列, 而动态列【不保留
+// 声明长度】(Flag 低 5 位必须为 0), 表一旦建出来, 那个 N 就不在盘上了 —— 事后再想校验
+// "这一列声明的容量兑现得了吗"已无据可查。放过去的后果正是声明侧闸门要拦的那种失败:
+// 建表成功、跑了一阵、某天写入某个大值时才撞 ErrHeapPageTooLarge, 用户看不出因果。
+func TestParseModelMaxlenCap(t *testing.T) {
+	// 下面 tag 里的字面量必须与实现同步 (单值上限 = core.MaxBlobSize, 由 core 侧的
+	// 编译期断言钉住 types 那一份)。对不上就在这里编译失败, 而不是变成一个"碰巧还过"的测试。
+	const atCap, overCap = 16360, 16361
+	var (
+		_ = [1]struct{}{}[atCap-types.MaxStringSize]     // 正好用满 = 上限
+		_ = [1]struct{}{}[overCap-types.MaxStringSize-1] // 比上限多一个字节
+	)
+
+	find := func(t *testing.T, mi *ModelInfo, col string) FieldDef {
+		t.Helper()
+		for _, fd := range mi.Fields {
+			if fd.ColName == col {
+				return fd
+			}
+		}
+		t.Fatalf("模型里没有列 %s", col)
+		return FieldDef{}
+	}
+
+	// 正好用满: 动态列装得下, 必须放行 —— 而且必须落成【动态列 + 宽度码 0】:
+	// 这个宽度码是行内布局的唯一依据, 若它被写成非 0, FieldSize 就会按档位算宽度,
+	// 整行字段从此错位 (比拒绝声明危险得多)。
+	t.Run("正好用满放行", func(t *testing.T) {
+		type AtCap struct {
+			BaseModel
+			Full string `vrtx:"maxlen:16360"`
+		}
+		mi, err := ParseModel(&AtCap{})
+		if err != nil {
+			t.Fatalf("声明 %d 字节应当放行: %v", atCap, err)
+		}
+		fd := find(t, mi, "Full")
+		if fd.FieldType != types.FieldType_Dynamicbyte {
+			t.Errorf("超过 %d 字节的声明应落动态列, 得到 %s",
+				types.MaxInlineColumnWidth, types.TypeNameOf(fd.FieldType))
+		}
+		if fd.Param() != 0 {
+			t.Errorf("动态列的宽度码必须留在 0 (Flag 低 5 位), 得到 %d", fd.Param())
+		}
+	})
+
+	// 多一个字节: 兑现不了, 必须拒。
+	t.Run("超过一个字节就拒", func(t *testing.T) {
+		type OverCap struct {
+			BaseModel
+			Big string `vrtx:"maxlen:16361"`
+		}
+		_, err := ParseModel(&OverCap{})
+		if err == nil {
+			t.Fatalf("声明 %d 字节必须被拒 (动态列装不下)", overCap)
+		}
+		// 报错要能直接定位: 哪个字段、用户写下的数、引擎能给的上限。
+		for _, want := range []string{"Big", strconv.Itoa(overCap), strconv.Itoa(types.MaxStringSize)} {
+			if !strings.Contains(err.Error(), want) {
+				t.Errorf("错误信息里缺少 %q: %v", want, err)
+			}
+		}
+	})
+
+	// 闸门只管字符串列: 数值列的 maxlen 本来就被忽略 (用 Go 类型自动推断), 不该因此报错。
+	t.Run("数值列不受影响", func(t *testing.T) {
+		type NumWide struct {
+			BaseModel
+			N int64 `vrtx:"maxlen:16361"`
+		}
+		if _, err := ParseModel(&NumWide{}); err != nil {
+			t.Errorf("数值列的 maxlen 应当被忽略, 不该报错: %v", err)
+		}
+	})
+}

+ 21 - 1
Makefile

@@ -5,7 +5,7 @@
         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
+        build-analyzer build-migrate-web demo-analyzer fmt lint deps tree
 
 # =============================================================================
 # 默认目标
@@ -85,6 +85,23 @@ build-migrate:
 	go build -o vrtxsql ./cmd/vrtxsql
 	@echo "✅ 构建完成: ./vrtxsql"
 
+# 构建跨库迁移的 Web 界面 (和 vrtxsql 同一套 migrate 能力, 只是把 flag 换成表单)
+build-migrate-web:
+	@echo "=== 构建 vrtxsqlweb ==="
+	go build -o vrtxsqlweb ./cmd/vrtxsqlweb
+	@echo "✅ 构建完成: ./vrtxsqlweb  (启动后打开 http://127.0.0.1:8099)"
+
+# 构建 Analyzer 可执行文件
+#
+# analyzer 是独立的 module (自带 go.mod), 所以 `go build ./...` 扫不到它 ——
+# 想拿到能直接跑、能挂调试器的二进制, 得进它自己的目录编。
+# 加 -gcflags="all=-N -l" 可关掉优化与内联 (断点更稳、变量能看全), 排查疑难时用:
+#   cd analyzer && go build -gcflags="all=-N -l" -o analyzer .
+build-analyzer:
+	@echo "=== 构建 analyzer ==="
+	cd analyzer && go build -o analyzer .
+	@echo "✅ 构建完成: ./analyzer/analyzer  (启动后打开 http://localhost:8081)"
+
 # =============================================================================
 # Demo 程序
 # =============================================================================
@@ -216,6 +233,7 @@ clean:
 	@rm -f *.db
 	@rm -f demo/*/*.exe
 	@rm -f vrtxsql vrtxsql.exe
+	@rm -f vrtxsqlweb vrtxsqlweb.exe
 	@rm -f analyzer/analyzer analyzer/analyzer.exe
 	@rm -f /tmp/tsdb2_demo_*.log
 	@echo "✅ 清理完成"
@@ -244,6 +262,8 @@ help:
 	@echo "🔀 跨库迁移"
 	@echo "  make test-migrate  - 迁移往返一致性测试 (VRTX ↔ SQLite)"
 	@echo "  make build-migrate - 构建 vrtxsql 命令行工具"
+	@echo "  make build-migrate-web - 构建 vrtxsqlweb 网页版迁移工具"
+	@echo "  make build-analyzer - 构建 analyzer 可执行文件"
 	@echo ""
 	@echo "🎨 Demo 程序"
 	@echo "  make test-demos    - 跑一遍全部 demo (排除重型, 逐个报通过/失败)"

+ 7 - 5
README.md

@@ -13,7 +13,7 @@ TsDB2 是一个**纯 Go** 的嵌入式数据库,同时服务两类场景:
 | **core 层** | `TsDB2/DB/core` | 页式存储、B+ 树 / Hash / 时序索引、事务、WAL、BufferPool 等底层实现 |
 | **迁移层** | `TsDB2/migrate` + `cmd/vrtxsql` | 与 MySQL / SQLite 互转(结构 + 数据 + 索引 + 主键),见 [跨库迁移](#跨库迁移vrtx--mysql--sqlite) |
 
-- 模块路径:`TsDB2`,Go 版本:`go 1.25`
+- 模块路径:`TsDB2`,Go 版本:`go 1.24`(以 `go.mod` 为准)
 - **引擎本体(`DB` / `DB/core`)不依赖任何第三方库**,只用标准库
 - 只有可选的 `migrate` / `cmd/vrtxsql` 需要 `github.com/go-sql-driver/mysql` 与 `modernc.org/sqlite`(纯 Go 的 SQLite);不跑迁移就不会链接到它们
 - 数据文件后缀任意(示例用 `.db`),页式布局见 [存储格式](#存储格式)
@@ -195,7 +195,7 @@ u.Delete()                          // 删
 | 写法 | 含义 |
 |------|------|
 | `vrtx:"pk"` | 该字段是本表**主键**(列名就是字段名),即**覆盖**所内嵌基类默认的键列(`_id` / `_ts` 不再建列)。写 `vrtx:"_id"` / `vrtx:"_ts"` 等价。注意它**不能替代基类**:只标 `pk` 而不内嵌基类报 `ErrNotModel` |
-| `vrtx:"maxlen:32"` | 字符串按 32 字节定长存储(≤32/64/128/256 就近取档,>256 转动态列) |
+| `vrtx:"maxlen:32"` | 字符串按 32 字节定长存储(≤32/64/128/256 就近取档,>256 转动态列);`N` 的上界是 **16360 字节**,超了 `ParseModel` 直接报错 |
 | `vrtx:"index"` | 建**默认 B+ 二级索引**,索引名自动为 `idx_<表名>_<列名>` |
 | `vrtx:"index:idx_age"` | 建 B+ 二级索引并指定索引名 |
 | `vrtx:"index:idx_age,type:hash"` | 建 Hash 二级索引 |
@@ -210,6 +210,8 @@ u.Delete()                          // 删
 - 一个结构体**只能有一个主键**:重复标 `pk`,或同时内嵌两个表基类,都报 `DB.ErrInvalidPrimaryKey`。但**内嵌基类 + 标 `pk` 是合法的** —— 那是"覆盖基类默认键列",正是自定义主键/时序键的用法。
 - 主键列**不参与 schema 同步**(不会因结构体改动被 `AddField` / `DropField`),建表时按模型建好,改主键字段名请重建表(换主键列 = 换索引键,需要迁移数据)。
 - `maxlen` 只对 `string` 生效;数值类型的宽度由 Go 类型决定,`maxlen` 被忽略。
+- **字符串的容量上限是 16360 字节**(一个堆页能装下的载荷):`maxlen` 超过 2048 的列落动态列,内容整体存进一个堆页,所以声明超过 16360 字节的列永远兑现不了 —— 这种声明在 `ParseModel` 阶段就报错(错误里给出上限与写下的值),而不是等某天写入大值时才失败。要存更大的内容请改用 `[]byte`(Blob),或把大列拆到附表。
+- **整行占用上限 8171 字节**(= (16KB 页 − 40B 数据页头 − 1) / 2,"一页至少装两行"这条政策的直接后果):每行除字段区还要占 4B 长度前缀 + 8B 行主键号 + 1B schemaVer + NULL 位图,所以**"字段区凑到 8K"其实两行装不进一页**。建表 / `AddField` 按**整行占用**校验,超限直接报错并列出各列宽度(降序);修法是把最宽的几列改成动态列(`maxlen>2048` 或 `[]byte`),或把大列拆到附表。
 - 多个字段声明**同一个具名索引** `index:NAME` 时,会自动合并为一个**复合索引**:分量顺序 = 字段在表中的物理顺序,索引类型取首次声明的类型,支持最左前缀(`a=?` 或 `a=? AND b>?`)。
 - **动态列(`[]byte`、`maxlen>256` 的字符串)不能建索引**:`UseTable` 会直接报错(动态列行内只存 6B 堆引用,做键无意义)。
 
@@ -880,7 +882,7 @@ TsDB2/
 | `go run ./demo/stress_small_config` | 9 组「小 BufferPool × 小阈值」配置 | **9 通过 / 0 失败** |
 | `go run ./demo/stress_extreme_config` | 7 组「极限小配置」(最小 BP=10 页) | **7 通过 / 0 失败** |
 | `go run ./demo/bench_10m` | 1000 万行主表 + 100 万行订单表:写入 / 查询 / 更新 / 删除 / Schema 变更 / 重开校验 | **校验 87 通过 / 0 失败** |
-| `go run ./demo/lock_table_test` | 写锁语义(只推迟落盘)/ Commit 并发不丢数据 / 缓存满淘汰干净页 | **全部通过** |
+| `go run ./demo/lock_table_test` | 写锁语义(只推迟落盘不拒绝)/ 写入中阈值触发提交不丢行 / 缓存满淘汰干净页 / 关闭重开仍在 | **全部通过** |
 
 ### 其他
 
@@ -1005,8 +1007,8 @@ SQL 表达不了 VRTX 的这些细节,所以导出时会在 SQL 侧额外建
 4. **Hash 索引不保序**:`type:hash` 的等值查询 O(1),但范围 / 排序查询退化为全桶扫描;需要范围 / 有序时请用默认 B+ 二级索引。
 5. **索引分量变更需显式处理**:表已存在时,若同名索引在模型中的分量列表与库中不一致,`UseTable` 会**报错**(避免静默使用少一列的索引),需先删索引再重建。
 6. **主键自动分配是「只增不减」的游标**:结构体主键为 0 时向引擎要一个自增号(首次使用时从当前最大存活 pkID 之后开始),**删除不会让编号回退**,不会与存活行撞号;结构体主键非 0 时按该值插入(`_id` 列与引擎 pkID 保持一致),重复报 `ErrDuplicatePK`。自动号不是真实时间戳。**时序表是唯一的例外**:时间键不是行号,所以无论 `_ts` 是否给了值,引擎都会分配一个新行号 —— 给了 `_ts` 就原样使用、不写回、也不当行号(同刻多行因此能共存);完全没给(`_ts == 0`)才会把刚分配的行号回填进 `_ts`,让这行至少落在某个明确时刻上(回填的是编号,不是真实时间戳)。
-7. **`LockWrites` 是「阻塞」而非「拒绝」,锁的是「页落盘」**:它挡住的是 `BufferPool.WritePage`(正在写盘的 goroutine 阻塞等待),`Insert` / `Update` / `Delete` 本身照常改内存中的页并标记脏,**不会返回任何错误** —— 引擎里没有"写入被拒绝"这种错误码。因此锁定期写入既不报错也不丢,只是落盘被推迟到 `UnlockWrites`(或 `Commit` 内部临时加锁结束)之后。`demo/lock_table_test` 按这套语义断言:锁定期写入不报错且一条不丢、Commit 期间并发写入不丢行、`BufferPool` 容量满时淘汰干净页而不是死锁。
-8. **单进程嵌入式**:仅面向单进程内多 goroutine;不提供多进程写并发控制与分布式复制。
+7. **`LockWrites` 是「阻塞」而非「拒绝」,锁的是「页落盘」**:它挡住的是 `BufferPool.WritePage`(正在写盘的 goroutine 阻塞等待),`Insert` / `Update` / `Delete` 本身照常改内存中的页并标记脏,**不会返回任何错误** —— 引擎里没有"写入被拒绝"这种错误码。因此锁定期写入既不报错也不丢,只是落盘被推迟到 `UnlockWrites`(或 `Commit` 内部临时加锁结束)之后。`demo/lock_table_test` 按这套语义断言:锁定期写入不报错且一条不丢、写入过程中脏页阈值触发提交不丢行、`BufferPool` 容量满时淘汰干净页而不是死锁、关闭重开后数据仍在。
+8. **单进程嵌入式,且是「单写入者」模型**:只面向单进程,**同一时刻只允许一个 goroutine 写** —— `Insert` / `Update` / `Delete` / `Commit` 都应由同一个写入者依次调用;不提供多进程写并发控制与分布式复制。多 goroutine 的正确用法是「一个写 + 只读」,但引擎对并发**读**没有一致性保证:一次写入要分多页依次落到缓存,读可能撞上写入的中间态(读到"新叶子 + 旧父节点"这种半成品)。要做到读取端严格一致,请把读写也串起来(或者写完之后再读)。**两个 goroutine 同时写、或一个 goroutine 写的同时另一个 goroutine 调 `Commit` 会崩**:`CollectDirty` 把脏页移出缓冲池之后、回写磁盘之前,这一页既不在缓存里也还不在盘上,并发的读会从盘上读到全零页(实测稳定 panic:`btPID: slice bounds out of range [-4:]`,页头 `EntryLen` 读到 0)。引擎内部没有写入串行化(`DB/core/pool/buffer_pool.go` 里关于 `capacityWait` 的注释写的就是「单写入者模型」),需要多写入者请在调用方自己加锁。写入与提交的「穿插」是支持的 —— 脏页阈值 / 防抖计时器触发的提交由引擎在写入者的 goroutine 里同步发起,不存在重叠。
 9. **批量删除开销较高**:`Deleter` 走「重建主键 + 二级索引」路径以保证正确性,峰值内存 / IO 高于单行删除(单行删除走 B+ 树借位 / 合并)。
 10. **空间放大**:文件按 16KB 页预分配,且索引 / 空闲页会带来冗余;实测 100 万行 `Employee` 约 122 bytes/行、放大 ≈ 1.7x。
 11. **`unique` 由唯一索引执行,且不约束 `NULL`**:约束落在索引上(插入 / 更新前先按键探一次),违反报 `core.ErrDuplicatedKey`。三个后果值得先知道:库中**已有重复值**时唯一索引**建不出来**(同一个错误,需先清理数据);表里已存在**同名非唯一索引**时 `UseTable` 直接报错,要先删索引再重建;主键 / 时序这类**行存储索引不支持**唯一约束(主键本就唯一,时序键允许同刻多行)。可空列的 `NULL` 不参与唯一性 —— 任一分量为 `NULL` 的行不进唯一索引,因此可以共存任意多行 `NULL`(复合唯一键亦然)。

+ 2 - 4
analyzer/indexcheck.go

@@ -721,8 +721,7 @@ func (s *keyScratch) extract(rowData []byte, idx IInfo, t *TInfo) []byte {
 	// 否则会把"合法不进索引的行"误报成缺失。
 	bitmapLen := (len(sv.Indices) + 7) / 8
 	fieldArea := 0
-	for _, fi8 := range sv.Indices {
-		fi := int(fi8)
+	for _, fi := range sv.Indices {
 		if fi >= len(t.MasterFields) {
 			return nil
 		}
@@ -737,8 +736,7 @@ func (s *keyScratch) extract(rowData []byte, idx IInfo, t *TInfo) []byte {
 	bitmap := rowData[1+fieldArea : 1+fieldArea+bitmapLen]
 	off := 1
 	need := n
-	for i, fi8 := range sv.Indices {
-		fi := int(fi8)
+	for i, fi := range sv.Indices {
 		size := t.MasterFields[fi].Sz
 		for k := 0; k < n; k++ {
 			if idx.Flds[k] != fi {

+ 117 - 18
analyzer/main.go

@@ -96,7 +96,9 @@ const (
 	IM_FLAG_UNIQUE  = 0x01
 
 	// 列 (FieldInfo) Flag 的位, 对应 core/types 的 Flag* 常量:
-	//   bit7 = 已删除 / bit6 = 可为 NULL / bit5 = 唯一 / bit4..bit0 = decimal scale
+	//   bit7 = 已删除 / bit6 = 可为 NULL / bit5 = 唯一
+	//   bit4..bit0 = 列级类型参数 (按类型解释: decimal 是 scale, 字符串列是宽度码)
+	// 掩码名沿用 FLAG_SCALE (core 侧叫 FlagScaleMask), 两个用途共用同一格。
 	FLAG_DELETED  = 0x80
 	FLAG_NULLABLE = 0x40
 	FLAG_UNIQUE   = 0x20
@@ -125,12 +127,14 @@ type MFInfo struct {
 	// Flag 列级标志字节, 随 schema 落盘 (对应 types.FieldInfo.Flag):
 	//
 	//	bit7  bit6       bit5    | bit4 ........ bit0
-	//	删除   可为 NULL   唯一    |  decimal scale (0..31)
+	//	删除   可为 NULL   唯一    |  列级类型参数 (0..31)
 	//
-	// decimal 的 scale 在低 5 位 (如 decimal:2 → 2); 删除标记不再占用 Typ 的最高位。
+	// 低 5 位按类型解释 (如 decimal:2 → 2, maxlen 声明出来的字符串列 → 宽度码);
+	// 删除标记不再占用 Typ 的最高位。
 
 	Flag byte `json:"flag"`
-	// Param decimal 的列级 scale (Flag 低 5 位), 非 decimal 列为 0。
+	// Param 列级类型参数 (Flag 低 5 位): decimal 是 scale, 字符串家族是行内宽度码,
+	// 其余类型为 0。
 	Param byte `json:"param"`
 	// Nullable / Unique 从 Flag 拆出来的状态位。
 	//
@@ -140,12 +144,27 @@ type MFInfo struct {
 	Nullable bool `json:"nullable"`
 	Unique   bool `json:"unique"`
 	Sz       int  `json:"sz"`
-	Dropped  bool `json:"dropped"`
+	// Label / Tip 类型徽章的展示文本与悬浮说明, 由 fLabel / fTip 在【后端】算好。
+	//
+	// 字符串列的 label 会显式带出行内宽度 (string(1024B)): 类型档 (256byte) 与真实
+	// 宽度矛盾时, 列表里能一眼看出这列到底占多大, 不必往右看 Size 列。
+	// 放在后端算与 nullable/unique 同一个理由: Flag 低 5 位是【按类型重载】的一格
+	// (decimal 是 scale, 字符串家族是宽度码), 前端若能拿到它, 迟早有人当字节数显示 ——
+	// 而"宽度码 20"对应的那列是 1024 字节, 差 50 倍。
+	Label   string `json:"label"`
+	Tip     string `json:"tip"`
+	Dropped bool   `json:"dropped"`
 }
 type VInfo struct {
-	Ver     byte     `json:"ver"`
-	Fields  []string `json:"fields"`
-	Indices []byte   `json:"indices"`
+	Ver    byte     `json:"ver"`
+	Fields []string `json:"fields"`
+	// Indices 本版字段在 masterFields 里的下标 (顺序 = 行内字段顺序)。
+	//
+	// 对外必须是 []int 而不是盘上原样的 []byte: 盘上每版字段数是 1 字节, 解析时直接
+	// copy 最省事, 但 []byte 过 JSON 会变成 base64 字符串 —— 前端 v.indices[i] 拿到的是
+	// 第 i 个字符, t.masterFields['A'] 恒为 undefined, "这一版每列多宽"整块静默消失。
+	// 宽化在解析层一次做完, 前端拿到的就是能直接当下标用的数字。
+	Indices []int `json:"indices"`
 }
 type FInfo struct {
 	Name string `json:"name"`
@@ -447,10 +466,14 @@ func readSchemaBytes(pageBuf []byte, recOff, alloc uint32, tb []byte) []byte {
 // flag 是**列级标志字节**, 紧跟在 typ 之后 (对应 types.FieldInfo.Flag):
 //
 //	bit7  bit6       bit5    | bit4 ........ bit0
-//	删除   可为 NULL   唯一    |  decimal scale (0..31)
+//	删除   可为 NULL   唯一    |  列级类型参数 (0..31)
+//
+// 字节位置与原来的 param 完全一致, 只是含义被扩展了: 低 5 位【按类型解释】——
+// decimal 是 scale (例如 `12 02 06 "Amount"` 就是 decimal(2)), 字符串家族是行内宽度码
+// (maxlen 声明出来的列靠它决定宽度)。其余类型低 5 位为 0, 但字节一定在。
 //
-// 字节位置与原来的 param 完全一致, 只是含义被扩展了: decimal 的 scale 仍在这里的低
-// 5 位 —— 例如 `12 02 06 "Amount"` 就是 decimal(2)。其余类型低 5 位为 0, 但字节一定在。
+// 所以下面算 Sz 时必须把 flag 一起传进去 (fSzWithParam): 只看 typ 会给宽度码列算成
+// 类型默认档 256, 而它是 2048 就整行错位 —— 与本函数注释开头说的那次事故同一类。
 //
 // 【这里漏掉这个字节曾造成整表读错】: 原实现按 `[typ][nameLen][name]` 走位, 于是把
 // 它当成了 nameLen —— 第一个字段名读成空串, 之后 masterFields / versions 全部错位。
@@ -480,10 +503,15 @@ func parseSchemaFields(b []byte) (byte, []MFInfo, []VInfo) {
 		name := strings.TrimRight(string(b[pos:pos+nameLen]), "\x00")
 		pos += nameLen
 		// typ 是纯类型值 (删除标记不再占最高位), 状态位全在 flag 里。
+		// param / sz 各算一次并复用: 宽度是"行内按多少字节付满"的唯一依据,
+		// 下面三处 (Sz / Label / Tip) 必须拿到同一个值, 分头算迟早会漂移。
+		param := flag & FLAG_SCALE
+		sz := fSzWithParam(typ, param)
 		masterFields = append(masterFields, MFInfo{
 			Name: name, Type: typ, TypeName: fName(typ),
-			Flag: flag, Sz: fSz(typ),
-			Param:    flag & FLAG_SCALE,
+			Flag: flag, Sz: sz, Param: param,
+			Label:    fLabel(typ, param, sz),
+			Tip:      fTip(typ, param, sz),
 			Dropped:  flag&FLAG_DELETED != 0,
 			Nullable: flag&FLAG_NULLABLE != 0,
 			Unique:   flag&FLAG_UNIQUE != 0,
@@ -502,15 +530,18 @@ func parseSchemaFields(b []byte) (byte, []MFInfo, []VInfo) {
 			}
 			fieldCount := int(b[pos])
 			pos++
-			indices := make([]byte, fieldCount)
+			// 下标逐个宽化成 int (见 VInfo.Indices): 盘上 1 字节/个, 但对外要数字数组。
+			indices := make([]int, fieldCount)
 			if pos+fieldCount <= len(b) {
-				copy(indices, b[pos:pos+fieldCount])
+				for i, raw := range b[pos : pos+fieldCount] {
+					indices[i] = int(raw)
+				}
 				pos += fieldCount
 			}
 			// Build field name list for this version
 			var fieldNames []string
 			for _, idx := range indices {
-				if int(idx) < len(masterFields) {
+				if idx < len(masterFields) {
 					mf := masterFields[idx]
 					n := mf.Name
 					if mf.Dropped {
@@ -540,8 +571,21 @@ func parseSchemaFields(b []byte) (byte, []MFInfo, []VInfo) {
 // 改成投影到 core 的 typeRegistry 之后, 这类漏同步在结构上不可能再发生: core 那边有
 // allTypes + CheckRegistry 兜底, 这里自动覆盖全部已声明类型。复用 core 的布局事实是
 // 本项目既有做法 (见 layout_assert.go 的编译期断言)。
+//
+// 注意它给的是【类型默认档】宽度; 行内真实宽度还要乘上列级参数 (字符串列的宽度码),
+// 行解码/索引键切分一律用 fSzWithParam。
 func fSz(t byte) int { return int(types.GetSize(types.FieldType(t))) }
 
+// fSzWithParam 列在行内的真实字节宽度 = types.FieldSize(类型, 列级参数)。
+//
+// param 取列 flag 的低 5 位 (字符串列是宽度码, decimal 是 scale)。少传它就会给
+// "maxlen 声明出来的字符串列"算出类型默认档 256, 而盘上那一列是 1024 字节 ——
+// off += sz 之后整行字段静默错位, 且错得"很像正常数据" (字段都有值, 只是全错位)。
+// 返回 0 表示类型未注册或参数非法: 调用方必须放弃解析该行, 不能当真值用。
+func fSzWithParam(t byte, param byte) int {
+	return int(types.FieldSize(types.FieldType(t), param))
+}
+
 func tNm(t byte) string {
 	switch t {
 	case 1:
@@ -573,6 +617,61 @@ func fName(t byte) string {
 	return "?"
 }
 
+// fLabel 类型徽章的展示文本 (MFInfo.Label)。
+//
+// 【为什么字符串列要改写】定长字符串家族的类型值只到 256byte 一档, 更宽的行内宽度靠
+// Flag 低 5 位的【宽度码】声明 (见 types.FieldSize)。于是同一份数据里会出现
+// "类型档 256byte、真实行宽 1024 字节" 的列 —— 列表里只写类型名, 看起来就是 256 字节,
+// 与盘上差了 4 倍, 而"字符串大小"这件事恰恰是看 schema 时最想知道的。
+// 所以宽度码不为 0 的字符串列显示成 string(1024B)。
+//
+// 宽度码为 0 (沿用类型默认宽度) 时保持类型名不变 —— v7 及以前落盘的 schema 低 5 位全为 0,
+// 那些库的显示逐字不变。
+//
+// decimal 显示 scale (与改动前一致); 其余类型就是类型名; 未注册类型给 "?"。
+func fLabel(t byte, param byte, sz int) string {
+	name := fName(t)
+	if name == "?" {
+		return unknownFieldValue
+	}
+	ft := types.FieldType(t)
+	switch {
+	case ft == types.FieldType_Decimal:
+		if param > 0 {
+			return fmt.Sprintf("%s(%d)", name, param)
+		}
+	case types.IsStringType(ft) && param > 0:
+		return fmt.Sprintf("string(%dB)", sz)
+	}
+	return name
+}
+
+// fTip 类型徽章的悬浮说明 (MFInfo.Tip): 把"类型档 + 列级参数 + 行内宽度"三件事一次讲清。
+//
+// 徽章上放不下这些, 而它们正是排查行宽问题时要对照的东西 —— 尤其是字符串列:
+// 盘上只存宽度码, 声明值本身【没有保留】, 因此这里只能给"向上取档后的宽度",
+// 这一点必须在提示里说破, 否则会有人拿它当声明值对账。
+func fTip(t byte, param byte, sz int) string {
+	name := fName(t)
+	if name == "?" {
+		return fmt.Sprintf("未注册的类型 %d: 行内宽度算不出来, 本行无法解码", t)
+	}
+	ft := types.FieldType(t)
+	switch {
+	case types.IsStringType(ft):
+		tip := fmt.Sprintf("定长字符串列: 类型档 %s, 行内宽度 %d 字节", name, sz)
+		if param > 0 {
+			tip += fmt.Sprintf(" (宽度码 %d; 声明容量向上取档后的宽度, 每行按它付满)", param)
+		}
+		return tip
+	case ft == types.FieldType_Decimal:
+		return fmt.Sprintf("定点小数: scale=%d, 行内宽度 %d 字节", param, sz)
+	case sz <= 0:
+		return fmt.Sprintf("类型 %s: 行内宽度算不出来", name)
+	}
+	return fmt.Sprintf("%s: 行内宽度 %d 字节", name, sz)
+}
+
 // ===== Read DataPage with schema version awareness =====
 
 // rDP reads a DataPage (sequential row layout, no directory)
@@ -656,7 +755,7 @@ func decodeRowData(rowData []byte, schemaV byte) string {
 		fieldArea := 0
 		sizesOK := true
 		for _, idx := range sv.Indices {
-			if int(idx) >= len(t.MasterFields) || t.MasterFields[idx].Sz <= 0 {
+			if idx >= len(t.MasterFields) || t.MasterFields[idx].Sz <= 0 {
 				sizesOK = false
 				break
 			}
@@ -705,7 +804,7 @@ func findVersion(t TInfo, ver byte) *VInfo {
 // TestDecodeCoversEveryRegisteredType 当场发现"core 加了新类型而这里没跟"。
 const unknownFieldValue = "?"
 
-// decodeFieldValue 把行内字节解成可读文本。约定: len(buf) == fSz(typ)。
+// decodeFieldValue 把行内字节解成可读文本。约定: len(buf) == 该列的 fSzWithParam(typ, param)。
 //
 // 与 DB/core 的 DecodeValue 是【两份独立实现】, 这是刻意的: analyzer 是"第二读取器",
 // 存在的意义就是独立验一遍 core 读出来的东西 —— 若共用同一份解码代码, core 解错时

+ 103 - 2
analyzer/main_test.go

@@ -1,8 +1,12 @@
 package main
 
 import (
+	"encoding/json"
+	"fmt"
 	"math/big"
 	"os"
+	"strconv"
+	"strings"
 	"testing"
 
 	"TsDB2/DB/core"
@@ -143,6 +147,61 @@ func TestUnknownFieldTypeIsExplicit(t *testing.T) {
 	}
 }
 
+// TestFieldLabelShowsRealWidth 类型徽章必须显示【真实行内宽度】。
+//
+// 字符串家族的类型值只到 256byte 一档, 更宽的列靠 Flag 低 5 位的宽度码声明 —— 于是
+// "类型档 256byte" 的列可以是 1024 字节宽。徽章上只写类型名, 看起来就是 256 字节, 与
+// 盘上差 4 倍; 而"字符串列有多大"恰恰是看 schema 时最想知道的一件事。所以宽字符串列
+// 显示成 string(<真实宽度>B)。这里钉住四条边界:
+//   - 宽度码列按真实宽度显示 (与 Size 列同源, 不打架);
+//   - 宽度码为 0 的列保持类型名 (v7 及以前落盘的 schema 低 5 位全为 0, 显示逐字不变);
+//   - decimal 仍显示 scale;
+//   - 其余类型不被改写。
+func TestFieldLabelShowsRealWidth(t *testing.T) {
+	wide := types.WidthCodeFor(1024) // 声明 1024 字节 → 向上取到的宽度码 (>0)
+	wideSz := int(types.FieldSize(types.FieldType_256byte, wide))
+	if wide == 0 || wideSz < 1024 {
+		t.Fatalf("宽度码 %d / 宽度 %d: core 的宽度表变了, 本测试的前提不成立", wide, wideSz)
+	}
+	cases := []struct {
+		name  string
+		typ   byte
+		param byte
+		want  string
+	}{
+		{"宽字符串列按真实宽度显示", byte(types.FieldType_256byte), wide, fmt.Sprintf("string(%dB)", wideSz)},
+		{"宽度码 0 保持类型名", byte(types.FieldType_256byte), 0, "256byte"},
+		{"最窄档同理", byte(types.FieldType_32byte), 0, "32byte"},
+		{"decimal 显示 scale", byte(types.FieldType_Decimal), 2, "decimal(2)"},
+		{"decimal 无 scale 不加括号", byte(types.FieldType_Decimal), 0, "decimal"},
+		{"普通类型不被改写", byte(types.FieldType_Float64), 0, types.TypeNameOf(types.FieldType_Float64)},
+		{"动态列不被改写", byte(types.FieldType_Dynamicbyte), 0, types.TypeNameOf(types.FieldType_Dynamicbyte)},
+		{"未注册类型", 0x7E, 0, unknownFieldValue},
+	}
+	for _, c := range cases {
+		sz := int(types.FieldSize(types.FieldType(c.typ), c.param))
+		if got := fLabel(c.typ, c.param, sz); got != c.want {
+			t.Errorf("%s: fLabel = %q, 期望 %q", c.name, got, c.want)
+		}
+	}
+	// 提示里必须说破"盘上只存宽度码、这是取档后的宽度": 声明值本身没有保留,
+	// 不说破就会有人拿这个数当声明值对账 (声明 1000 会显示成档位 1024)。
+	if tip := fTip(byte(types.FieldType_256byte), wide, wideSz); !strings.Contains(tip, "宽度码") {
+		t.Errorf("宽度码列的 tip = %q, 未说明宽度码来源", tip)
+	}
+	// 序列化后必须带着 label / tip: 前端只认 JSON 的 key, 少一个就退回"只显示类型档名",
+	// 而"字符串大小"恰好挂在 label 上。这个契约跨了语言边界, 编译器不会替我们看着。
+	b, err := json.Marshal(MFInfo{Name: "Wide", Label: "string(1024B)", Tip: "行内宽度 1024 字节"})
+	if err != nil {
+		t.Fatalf("json.Marshal(MFInfo): %v", err)
+	}
+	for _, k := range []string{`"label":"string(1024B)"`, `"tip":"行内宽度 1024 字节"`} {
+		if !strings.Contains(string(b), k) {
+			t.Errorf("MFInfo 序列化 = %s, 缺 %s —— 前端拿不到就不会显示", b, k)
+		}
+	}
+}
+
 // ============================ schema 格式同步守卫 ============================
 
 // TestParseSchemaAgainstCore analyzer 的 schema 解析必须与 core 写盘的字节逐字节一致。
@@ -200,6 +259,9 @@ func TestParseSchemaAgainstCore(t *testing.T) {
 		{FieldType: types.FieldType_Int128, FieldName: []byte("Big")},
 		{FieldType: types.FieldType_Blob, FieldName: []byte("Body")},
 		{FieldType: types.FieldType_256byte, FieldName: []byte("Note")},
+		// 用宽度码声明出来的宽字符串列: 它的行内宽度 (1024) 不等于类型默认档 (256)。
+		// 少了这一列, "Sz 忘了带列级参数" 这类漏同步在这个测试里是看不见的。
+		{FieldType: types.FieldType_256byte, Flag: types.WidthCodeFor(1024), FieldName: []byte("Wide")},
 	}
 
 	db, err := core.Open(path)
@@ -247,11 +309,34 @@ func TestParseSchemaAgainstCore(t *testing.T) {
 			t.Errorf("字段 %d flag = %d, 期望 %d (状态位与 decimal scale 都在这里)",
 				i, g.Flag, want.Flag)
 		}
-		if g.Sz != int(types.GetSize(want.FieldType)) {
-			t.Errorf("字段 %d 宽度 = %d, 期望 %d", i, g.Sz, types.GetSize(want.FieldType))
+		if g.Param != want.Param() {
+			t.Errorf("字段 %d 列级参数 = %d, 期望 %d (状态位与列级参数共用一个字节, 取值串位了)",
+				i, g.Param, want.Param())
+		}
+		// 宽度要按【列级参数】算: 字符串列的宽度码 (maxlen 声明) 决定真实行宽,
+		// 只看类型会一律算成 256, 而盘上那列可能是 1024 —— 行解码 off += sz 全错位。
+		if wantSz := int(types.FieldSize(want.FieldType, want.Param())); g.Sz != wantSz {
+			t.Errorf("字段 %d 宽度 = %d, 期望 %d (类型 %s, 列级参数 %d)",
+				i, g.Sz, wantSz, types.TypeNameOf(want.FieldType), want.Param())
+		}
+		// label / tip 是列表里唯一直接显示"这一列多大"的地方 (徽章 + 悬浮说明),
+		// 空掉就等于前端退回只显示类型档名 —— 而宽度码列的档名 (256byte) 与真实
+		// 宽度 (1024) 能差 4 倍。所以这里连同 Sz 一起钉住, 保证两者同源。
+		if g.Label == "" || g.Tip == "" {
+			t.Errorf("字段 %d (%s) 的 label/tip 为空, 列表里没有类型/宽度可显示", i, g.Name)
+		}
+		if !strings.Contains(g.Tip, strconv.Itoa(g.Sz)) {
+			t.Errorf("字段 %d (%s) 的 tip = %q 未提到行内宽度 %d", i, g.Name, g.Tip, g.Sz)
 		}
 	}
 
+	// 宽度码声明出来的那列, 徽章上必须能看到真实宽度 (1024), 而不是类型档名 (256byte) ——
+	// 这正是"字符串大小显示在列表里"要落地的那个位置: 类型那一格就得看得见。
+	wide := tb.MasterFields[len(fields)-1]
+	if wide.Name != "Wide" || !strings.Contains(wide.Label, "1024") {
+		t.Errorf("宽字符串列 %s 的 label = %q, 期望带出真实行内宽度 1024", wide.Name, wide.Label)
+	}
+
 	// 版本链在 masterFields 之后, 是错位的第一个受害者 —— 单独钉住
 	if len(tb.Versions) != 1 {
 		t.Fatalf("versions = %d 个, 期望 1 个", len(tb.Versions))
@@ -262,6 +347,22 @@ func TestParseSchemaAgainstCore(t *testing.T) {
 	}
 	if n := len(tb.Versions[0].Indices); n != len(fields) {
 		t.Errorf("主版本索引 = %d 个, 期望 %d 个", n, len(fields))
+	} else {
+		for i, fi := range tb.Versions[0].Indices {
+			if fi != i { // 建表后未动过 schema, 本版字段就是 masterFields 的 0..n-1
+				t.Errorf("版本字段下标[%d] = %d, 期望 %d", i, fi, i)
+			}
+		}
+	}
+	// VInfo.Indices 必须序列化成【数字数组】: 它是 []byte 时 JSON 会变成 base64 字符串
+	// ("AAECAwQ="), 前端 v.indices[i] 拿到的是第 i 个字符, masterFields['A'] 恒为
+	// undefined —— "这一版每列多宽"整块静默消失, 而页面只是少一行悬浮说明, 不报错。
+	vJSON, err := json.Marshal(tb.Versions[0])
+	if err != nil {
+		t.Fatalf("json.Marshal(VInfo): %v", err)
+	}
+	if !strings.Contains(string(vJSON), `"indices":[0,1`) {
+		t.Errorf("VInfo 序列化 = %s, 期望 \"indices\" 是数字数组", vJSON)
 	}
 }
 

+ 37 - 6
analyzer/web/index.html

@@ -104,6 +104,13 @@ a{color:#5b9bd5;text-decoration:none}
 const $=id=>document.getElementById(id);
 const esc=s=>s?s.replace(/</g,'&lt;').replace(/>/g,'&gt;'):'';
 function fB(n){n=+n;if(n<1024)return n+' B';if(n<1048576)return(n/1024).toFixed(1)+' KB';if(n<1073741824)return(n/1048576).toFixed(2)+' MB';return(n/1073741824).toFixed(2)+' GB'}
+// escAttr 放进 HTML 属性的文本: esc 只挡 < >, 而属性里还得挡住引号 ——
+// 截断后的键 / 原始十六进制全文要放 title, 值里带一个 " 就会把属性提前截断。
+const escAttr=s=>esc(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
+// clip 长文本截断显示: 宽字符串列会把键与原始十六进制推到几千字符 (1024 字节的键 =
+// 2048 个十六进制字符), 原样塞进单元格会把表格拉成一条长带。截断但保留全文 (调用方放
+// title), 并注明原始长度 —— 截断只该省地方, 不该让人以为数据本来就短。
+function clip(s,n){s=s==null?'':String(s);if(s.length<=n)return esc(s);return esc(s.slice(0,n))+'<span style="color:#556"> …共 '+s.length+' 字符</span>'}
 function ni(lb,val){return '<div class="ni"><span class="ni-lb">'+lb+'</span><span class="ni-val">'+val+'</span></div>'}
 function bigCard(cls,lb,val,sub){return '<div class="card '+cls+'"><div class="lb">'+lb+'</div><div class="val">'+val+'</div>'+(sub?'<div class="sub">'+sub+'</div>':'')+'</div>'}
 const ITYPE={1:'Time',2:'Primary',3:'String',4:'Compose',5:'BTree',6:'Hash'};
@@ -418,8 +425,13 @@ function showTable(nm){
   // 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>':'');
+  // 徽章文本 (label) 与悬浮说明 (tip) 都由后端算好: Flag 低 5 位是【按类型重载】的一格
+  // (decimal 是 scale, 字符串家族是宽度码), 前端若能拿到它, 迟早有人把宽度码当字节数显示 ——
+  // "256byte(20)" 会被读成 20 字节, 而那一列真实宽度是 1024。字符串列的 label 因此直接
+  // 带出行内宽度 (string(1024B)), 与右边 Size 列同源, 不再有"类型名与真实宽度打架"的情况。
   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>'+fl+'</td><td>'+f.sz+' bytes</td><td>'+st+'</td></tr>';
+  '<td><span class="badge primary" title="'+escAttr(f.tip||'')+'">'+esc(f.label||f.typeName)+'</span>'+fl+'</td>'+
+  '<td title="行内宽度: 本列在每一行里固定占 '+f.sz+' 字节 (定长, 不足补零)">'+f.sz+' bytes</td><td>'+st+'</td></tr>';
  });
  h+='</tbody></table></div></div>';
 
@@ -433,7 +445,13 @@ function showTable(nm){
   h+='</div><div class="ver-fields">';
   if(v.fields)v.fields.forEach((fn,fi)=>{
    let isDrop=fn.includes('(dropped)');
-   h+='<span class="ver-field '+(isDrop?'dropped':'active')+'">'+esc(fn)+'</span>';
+   // 字段片只放名字 (一版几十列时再塞类型会糊成一片), 但把"类型 + 行内宽度"挂进 title:
+   // v.indices 是本版字段在 masterFields 里的【数字下标数组】(后端保证, 见 VInfo.Indices) ——
+   // 别当盘上字节用: 后端若退回 []byte, JSON 会变成 base64 字符串, v.indices[i] 取到的是
+   // 第 i 个字符, masterFields['A'] 恒为 undefined, 整块说明静默消失 (只是不显示, 不报错)。
+   let mi=(t.masterFields&&v.indices&&v.indices[fi]!=null&&v.indices[fi]<t.masterFields.length)?t.masterFields[v.indices[fi]]:null;
+   let tip=mi?(mi.name+' · '+(mi.label||mi.typeName)+' · 行内 '+mi.sz+' 字节'+(mi.tip?'\n'+mi.tip:'')):'';
+   h+='<span class="ver-field '+(isDrop?'dropped':'active')+'" title="'+escAttr(tip)+'">'+esc(fn)+'</span>';
   });
   h+='</div></div>';
  });
@@ -443,12 +461,21 @@ function showTable(nm){
  h+='<div class="sec"><div class="sec-hd"><div class="dot" style="background:#5b9bd5"></div>Indexes</div><div class="sec-bd"><div class="idx-list">';
  if(!t.idxs||!t.idxs.length)h+='<div class="empty"><div class="msg">No indexes</div></div>';
  else t.idxs.forEach(idx=>{
-  let flds=(idx.flds||[]).map(fi=>t.masterFields&&t.masterFields[fi]?t.masterFields[fi].name:'?').join(', ');
+  // 分量后缀上宽度: 索引键是各分量按【真实行内宽度】拼起来的, 宽字符串分量会让键变得很长
+  // (B+ 树每页能放的条目数直接塌下去), 只看列名看不出这件事。
+  const mf=fi=>t.masterFields&&t.masterFields[fi]?t.masterFields[fi]:null;
+  const flds=(idx.flds||[]).map(fi=>{const m=mf(fi);return m?m.name+'('+m.sz+'B)':'?'}).join(', ');
+  // 二级索引条目 = [各分量宽度之和][pkID 8B]; 行存储索引 (Time/Primary) 的键就是 pkID,
+  // 行数据跟在键后面 —— 两种布局不同, 一律按分量求和会给出错的键长, 所以分开算。
+  const secondary=(idx.type>=3&&idx.type<=6);
+  const keyLen=secondary?(idx.flds||[]).reduce((a,fi)=>{const m=mf(fi);return a+(m?m.sz:0)},0)+8:0;
   let ic=ICOLOR[idx.type]||'primary';
   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>'+(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-meta"><span class="badge '+ic+'">'+idx.ts+'</span>'+(idx.uniq?'<span class="badge btree" title="唯一索引: 同一索引键只允许一行 (任一分量为 NULL 的行不进索引)">UNIQUE</span>':'')+'<span>Fields: '+esc(flds)+'</span>'+
+  (secondary?'<span title="条目布局: 键(各分量按真实行内宽度拼接) + pkID(8B)。宽字符串分量会把键拉长, 每页条目数与树高都随之上升">Key: '+keyLen+' B</span>':'')+
+  '<span>Root: '+idx.ep+'</span></div></div>'+
    '<div class="idx-arrow">&#x203A;</div></div>';
  });
  h+='</div></div></div>';
@@ -512,7 +539,9 @@ function loadIdxPage(pid,it){
   }
   h+='</tr></thead><tbody>';
   (d.ents||[]).forEach((e,i)=>{
-   h+='<tr><td class="mono" style="color:#556">'+i+'</td><td class="mono" style="color:#c8ccd4">'+esc(e.k)+'</td>';
+   // 键按真实宽度拼, 宽字符串分量的 B+ 树叶条目键可达上千字节 → 十六进制上千字符;
+   // 截断显示 (全文在 title 里), 否则一页几十条会把表格撑成一条竖带。
+   h+='<tr><td class="mono" style="color:#556">'+i+'</td><td class="mono" style="color:#c8ccd4" title="'+escAttr(e.k)+'">'+clip(e.k,96)+'</td>';
    if(isBtree){
     if(d.lf)h+='<td><span class="badge data clickable" onclick="showDP('+e.p+')">'+e.p+'</span></td>';
     else h+='<td><span class="badge primary clickable" onclick="drillIdx('+e.p+')">'+e.p+'</span></td>';
@@ -553,7 +582,9 @@ function showDP(pid){
    '<td class="mono" style="color:#f7b731;font-size:12px">'+r.pk+'</td>'+
    '<td><span class="badge active">v'+r.sv+'</span></td>'+
    '<td class="row-decoded">'+esc(r.decoded)+'</td>'+
-   '<td class="hex-data">'+esc(r.h)+'</td></tr>';
+   // 原始十六进制按整行长度算: 一行里有 1024/2048 字节的字符串列时是几千个字符,
+   // 截断显示 (全文在 title 里); 解码列不截 —— 那正是要看的字符串内容本身。
+   '<td class="hex-data" title="'+escAttr(r.h)+'">'+clip(r.h,240)+'</td></tr>';
   });
   rows+='</tbody></table>';
   let nav='';

+ 262 - 11
cmd/vrtxsqlweb/index.html

@@ -127,6 +127,55 @@
   .log .stage { color: #fcd34d; font-weight: 600; margin-top: 3px; }
   .log .r { color: #94a3b8; } /* 逐行数据: 刻意低调, 免得盖过关键日志 */
 
+  /* 执行区一分为二: 左边表清单 (自己带滚动条), 右边还是那面滚动的日志墙。
+     日志不筛不拆 —— 左表负责回答"现在迁到哪张、那张什么状态", 点一下能跳过去。
+     两栏各自 overflow: auto, 一个撑不长, 另一个也不跟着抖。 */
+  .split { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 10px; margin-top: 14px; }
+  @media (max-width: 640px) { .split { grid-template-columns: 1fr; } }
+  .split .log { margin: 0; }
+  .side {
+    position: relative;                 /* 子项 offsetTop 要相对这里算, 好做滚动定位 */
+    border: 1px solid var(--line); border-radius: 9px; background: #fcfcfe;
+    max-height: 380px; min-height: 90px; overflow: auto; scrollbar-width: thin;
+  }
+  .sideHead {
+    position: sticky; top: 0; z-index: 1;
+    display: flex; align-items: center; gap: 6px;
+    padding: 7px 10px; background: #f4f5f8; border-bottom: 1px solid var(--line);
+    font-size: 12px; color: var(--dim);
+  }
+  .sideHead b { color: var(--ink); }
+  .sideHead .pin {
+    margin-left: auto; max-width: 132px; color: var(--brand); font-weight: 560;
+    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+  }
+  .sideList { padding: 5px; }
+  .sideList .empty { display: block; padding: 5px 6px; color: #9ca3af; font-size: 12px; }
+  .ti {
+    padding: 6px 8px; border: 1px solid transparent; border-radius: 7px;
+    font-size: 12.5px; line-height: 1.5; cursor: pointer;
+  }
+  .ti:hover { background: #eef0f6; }
+  .ti .nm { display: flex; align-items: center; gap: 6px; overflow-wrap: anywhere; }
+  .ti .nm b { font-weight: 560; }
+  .ti .dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: #d1d5db; }
+  .ti .sub {
+    margin-left: 13px; font-family: var(--mono); font-size: 11px;
+    color: var(--dim); overflow-wrap: anywhere;
+  }
+  .ti.run { background: #eef2ff; border-color: #c7d2fe; }
+  .ti.run .dot { background: var(--brand); animation: pulse 1s ease-in-out infinite; }
+  .ti.ok .dot { background: var(--ok); }
+  .ti.ok .sub { color: #4d7c5f; }
+  .ti.skip .dot { background: var(--warn); }
+  .ti.skip .sub { color: var(--warn); }
+  .ti.bad { background: #fef2f2; border-color: #fecaca; }
+  .ti.bad .dot { background: var(--err); }
+  .ti.bad .sub { color: var(--err); }
+  /* 失败原因挂在这行的 title 上 (行里放不下): 虚线 + 问号光标提示"悬停能看全" */
+  .ti.bad .sub { text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; }
+  .ti.pick { box-shadow: inset 0 0 0 2px var(--brand); }
+
   /* 进行中那一行: 计时器在走 + 行数在涨 = 没死锁 */
   .progress {
     display: flex; align-items: center; gap: 9px;
@@ -390,7 +439,13 @@
         <input type="checkbox" id="follow" checked> 自动滚到底部
       </label>
     </div>
-    <pre id="log" class="log"><span class="empty">等待开始…</span></pre>
+    <div class="split">
+      <div class="side" id="tblSide">
+        <div class="sideHead">表 <b id="tblCount">0</b><span class="pin" id="pinHint" hidden></span></div>
+        <div class="sideList" id="tblList"><span class="empty">等待开始…</span></div>
+      </div>
+      <pre id="log" class="log"><span class="empty">等待开始…</span></pre>
+    </div>
     <div id="progress" class="progress" hidden></div>
     <div id="report"></div>
   </section>
@@ -550,10 +605,13 @@ function trimLog() {
   }
 }
 
-function logLine(text, cls) {
+// table 用来给行打标记 (点左表的某一项能跳到它的第一行); 不给就跟着"正在迁的那张"
+function logLine(text, cls, table) {
   if (logBox.querySelector('.empty')) logBox.innerHTML = '';
   const line = document.createElement('div');
   if (cls) line.className = cls;
+  const t = table === undefined ? curTable : table;
+  if (t) line.dataset.t = t;
   line.textContent = text;
   logBox.appendChild(line);
   trimLog();
@@ -565,8 +623,8 @@ function logLine(text, cls) {
 let rowQueue = [];
 let rowTimer = null;
 
-function pushRow(text) {
-  rowQueue.push(text);
+function pushRow(text, table) {
+  rowQueue.push({ text, table });
   if (!rowTimer) rowTimer = setTimeout(flushRows, 30);
 }
 
@@ -574,10 +632,11 @@ function flushRows() {
   rowTimer = null;
   if (rowQueue.length === 0) return;
   const frag = document.createDocumentFragment();
-  for (const t of rowQueue) {
+  for (const it of rowQueue) {
     const d = document.createElement('div');
     d.className = 'r';
-    d.textContent = t;
+    if (it.table) d.dataset.t = it.table;
+    d.textContent = it.text;
     frag.appendChild(d);
   }
   rowQueue = [];
@@ -587,6 +646,179 @@ function flushRows() {
   if (followTail) scrollLogTail();
 }
 
+/* ---------------- 左侧表清单: 迁到哪张、那张什么状态 ---------------- */
+const tblSide = $('tblSide');
+const tblList = $('tblList');
+/* 表名 → 清单里的那一行。只增不重排: 状态在变而位置不动, 高亮才不会满屏跳。 */
+const tblRows = new Map();
+let curTable = '';   // 正在迁的那张 (左表自动高亮它)
+let pickTable = '';  // 用户点选锁定的那张 (再点一次取消); 空 = 跟随正在迁的
+let pinTimer = null;
+
+/* 本次要迁哪些表: 由勾选情况决定 —— 全选/不选都当"全部表"(与后端同一套语义)。 */
+function plannedTables() {
+  const all = [...document.querySelectorAll('.tblChk')];
+  const picked = all.filter((c) => c.checked).map((c) => c.value);
+  if (all.length === 0 || picked.length === all.length) return all.map((c) => c.value);
+  return picked;
+}
+
+function resetTablePane(names) {
+  tblRows.clear();
+  tblList.innerHTML = '';
+  curTable = '';
+  pickTable = '';
+  clearTimeout(pinTimer);
+  $('tblCount').textContent = '0';
+  if (!names.length) {
+    tblList.innerHTML = '<span class="empty">等待开始…</span>';
+    paintTables();
+    return;
+  }
+  names.forEach((n) => addTableRow(n));
+  paintTables();
+}
+
+/* 结构信息取自「查看源库结构」的缓存; 没点过那一步就只剩表名 —— 不编造列数。 */
+function colHint(name) {
+  const t = schemaCache.find((x) => x.name === name);
+  if (!t) return '';
+  const cols = t.columns ? t.columns.length : 0;
+  const idxs = t.indexes ? t.indexes.length : 0;
+  return `${cols} 列 · ${idxs} 索引 · 主键 ${t.pkColumn || '无'}`;
+}
+
+function addTableRow(name) {
+  const had = tblRows.get(name);
+  if (had) return had;
+  if (tblList.querySelector('.empty')) tblList.innerHTML = '';
+  const el = document.createElement('div');
+  el.className = 'ti';
+  const nm = document.createElement('div');
+  nm.className = 'nm';
+  const dot = document.createElement('span');
+  dot.className = 'dot';
+  const b = document.createElement('b');
+  b.textContent = name;
+  nm.append(dot, b);
+  const sub = document.createElement('div');
+  sub.className = 'sub';
+  sub.textContent = colHint(name);
+  el.append(nm, sub);
+  el.onclick = () => {
+    pickTable = pickTable === name ? '' : name;
+    paintTables();
+    if (pickTable) jumpToTable(name);
+  };
+  tblList.appendChild(el);
+  tblRows.set(name, el);
+  $('tblCount').textContent = String(tblRows.size);
+  return el;
+}
+
+/* state: run / ok / bad / skip, 空 = 未开始。
+   进度一秒可能刷很多次, 所以这里只写 textContent (不读布局), 只有"状态真的变了"
+   才重画 —— 否则每来一条进度都要读一次 offsetTop, 大表上白白拖慢界面。
+   tip: 悬停提示, 用来放"行里放不下"的细节 (失败原因、备注); 不传就清掉, 免得
+   留着上一次的旧话。 */
+function setTableState(name, state, sub, tip) {
+  const el = addTableRow(name);
+  const st = state || '';
+  const changed = el.dataset.st !== st;
+  el.dataset.st = st;
+  el.classList.remove('run', 'ok', 'bad', 'skip');
+  if (st) el.classList.add(st);
+  if (sub) el.querySelector('.sub').textContent = sub;
+  el.title = tip || '';
+  if (changed) paintTables();
+}
+
+function paintTables() {
+  tblRows.forEach((el, name) => {
+    // 已经判失败的表不再亮"进行中": 否则红点还在一跳一跳, 看着像还在跑
+    el.classList.toggle('run', name === curTable && !el.classList.contains('bad'));
+    el.classList.toggle('pick', name === pickTable);
+  });
+  const pin = $('pinHint');
+  pin.hidden = !pickTable;
+  if (pickTable) pin.textContent = '已锁定';
+  revealActive();
+}
+
+/* 亮着的那张要是滚出了可视区, 就把它带回来 —— 表多时不然等于没高亮 */
+function revealActive() {
+  const el = tblRows.get(pickTable || curTable);
+  if (!el || !tblSide) return;
+  const top = el.offsetTop;
+  const bottom = top + el.offsetHeight;
+  if (top < tblSide.scrollTop || bottom > tblSide.scrollTop + tblSide.clientHeight) {
+    tblSide.scrollTop = Math.max(0, top - (tblSide.clientHeight - el.offsetHeight) / 2);
+  }
+}
+
+const cssEsc = (s) => (window.CSS && CSS.escape ? CSS.escape(s) : String(s).replace(/["\\]/g, '\\$&'));
+
+/* 点表名 → 右边日志滚到这张表的第一行。日志不筛不拆, 只是把人送过去; 之后照旧往下滚
+   (往上翻会触发已有的滚动逻辑, 自动把"自动滚到底"取消掉, 不会被拽回底部)。 */
+function jumpToTable(name) {
+  const first = logBox.querySelector(`[data-t="${cssEsc(name)}"]`);
+  if (!first) { flashPin('日志里已找不到'); return; }
+  first.scrollIntoView({ block: 'start' });
+}
+
+function flashPin(text) {
+  const pin = $('pinHint');
+  pin.hidden = false;
+  pin.textContent = text;
+  clearTimeout(pinTimer);
+  pinTimer = setTimeout(paintTables, 2600);
+}
+
+/* 正在迁的表名从两类事件推出:
+   结构化事件 (progress / row) 自带 table; 但 0 行的表不会有这两类事件, 所以再认一下
+   后端每张表开头必打的那行日志 "[migrate] 表 X (i/n): 开始"。 */
+const START_RE = /^\[migrate\] 表 (.+) \(\d+\/\d+\): 开始$/;
+/* 后半截 ": <错误原文>" 必须留住 —— 左栏一行只放得下"失败, 已跳过", 真正的原因挂到
+   title 上悬停可见 (错误原文常常很长, 直接塞进那一行会把行撑成一大块)。
+   原因用 [\s\S]* 而不是 .*: 驱动/SQL 的报错原文常自带换行, 用 . 会整行都匹配不上,
+   失败的表就既不变红也拿不到原因 —— 那才是真的"只有失败、没有原因"。 */
+const FAIL_RE = /^\[migrate\] 表 (.+?) 失败, 跳过继续(?::\s*([\s\S]*))?$/;
+
+function setCurrentTable(name) {
+  if (!name) return;
+  addTableRow(name);
+  if (curTable === name) return;
+  curTable = name;
+  paintTables();
+}
+
+/* 左栏就两指宽, 而错误原文动不动好几行 (SQL 报错、驱动报错都长): 全塞进 sub 会把
+   那一行撑成一大块, 上下邻居全被推走。所以 sub 只留第一行的头一小截, 全文交给
+   title 悬停看 —— 想读完整原因, 鼠标停在行上就行。 */
+function shortWhy(s, max = 56) {
+  const one = String(s == null ? '' : s).split('\n')[0].trim();
+  const r = [...one];
+  return r.length > max ? r.slice(0, max).join('') + '…' : one;
+}
+
+/* 结束时按报告落最终状态: 报告是唯一权威 —— 小表可能一次 progress 都没发过。 */
+function finishTables(rep) {
+  curTable = '';
+  ((rep && rep.tables) || []).forEach((t) => {
+    const state = t.error ? 'bad' : (t.skipped ? 'skip' : 'ok');
+    let sub;
+    if (t.error) sub = '失败:' + shortWhy(t.error);
+    else if (t.skipped) sub = t.note || '跳过';          // 跳过的表行数没意义, 只说原因
+    else {
+      sub = `${Number(t.rows).toLocaleString()} 行 · ${t.elapsed}`;
+      if (t.note) sub += ' · ' + t.note;                 // 备注 (试跑/仅结构/截断/跳过索引) 不能丢
+    }
+    const tip = t.error ? `表 ${t.table} 失败:${t.error}` : (t.note ? '备注:' + t.note : '');
+    setTableState(t.table, state, sub, tip);
+  });
+  paintTables();
+}
+
 function fmtDur(ms) {
   const s = Math.max(0, Math.floor(ms / 1000));
   return s < 60 ? s + 's' : Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's';
@@ -642,6 +874,7 @@ $('runBtn').onclick = async () => {
   runHadError = false;
   $('report').innerHTML = '';
   $('progress').hidden = true;
+  resetTablePane(plannedTables());
   followTail = true;
   $('follow').checked = true;
   startClock();
@@ -685,36 +918,54 @@ $('runBtn').onclick = async () => {
     flushRows();
     $('progress').hidden = true;
     btn.disabled = false;
+    curTable = '';   // 没有表在迁了, 别让最后那张一直亮着"进行中"
+    paintTables();
   }
 };
 
 function handleEvent(ev) {
   if (ev.type === 'log') {
-    logLine(`[${ev.at || ''}] ${ev.msg}`, ev.level === 'stage' ? 'stage' : null);
+    const msg = String(ev.msg || '');
+    const started = START_RE.exec(msg.trim());
+    if (started) setCurrentTable(started[1]);   // 先认表, 这行日志才挂得上标记
+    logLine(`[${ev.at || ''}] ${msg}`, ev.level === 'stage' ? 'stage' : null);
+    const failed = FAIL_RE.exec(msg.trim());
+    if (failed) {
+      const why = (failed[2] || '').trim();
+      setTableState(failed[1], 'bad', '失败,已跳过', why ? `表 ${failed[1]} 失败:${why}` : '');
+    }
   } else if (ev.type === 'row') {
-    pushRow(`${ev.table} #${Number(ev.rows).toLocaleString()}  ${ev.msg}`);
+    setCurrentTable(ev.table);
+    pushRow(`${ev.table} #${Number(ev.rows).toLocaleString()}  ${ev.msg}`, ev.table);
   } else if (ev.type === 'progress') {
+    setCurrentTable(ev.table);
+    setTableState(ev.table, 'run', `${Number(ev.rows).toLocaleString()} 行 · ${ev.elapsed}`);
     showProgress(ev.table, ev.rows, ev.elapsed);
   } else if (ev.type === 'error') {
     runHadError = true;
     logLine('错误:' + ev.msg, 'e');
+    if (curTable) setTableState(curTable, 'bad', '中断', '错误:' + ev.msg);
   } else if (ev.type === 'done') {
     $('progress').hidden = true;
     renderReport(ev.report);
+    finishTables(ev.report);
   }
 }
 
 function renderReport(rep) {
   if (!rep) return;
-  const rows = (rep.tables || []).map((t) => `
+  const rows = (rep.tables || []).map((t) => {
+    const note = t.error ? '失败:' + t.error : (t.note || (t.skipped ? '跳过' : ''));
+    return `
     <tr class="${t.error ? 'bad' : (t.skipped ? 'skip' : '')}">
       <td>${esc(t.table)}</td>
       <td class="num">${t.rows}</td>
       <td class="num">${t.columns}</td>
       <td class="num">${t.indexes}</td>
       <td class="num">${t.elapsed}</td>
-      <td>${esc(t.error ? '失败:' + t.error : (t.note || (t.skipped ? '跳过' : '')))}</td>
-    </tr>`).join('');
+      <td title="${esc(note)}">${esc(note)}</td>
+    </tr>`;
+  }).join('');
   const bad = (rep.tables || []).filter((t) => t.error).length;
   $('report').innerHTML = `
     <table class="rep">

BIN
demo/.DS_Store


+ 7 - 1
demo/bench_10m/main.go

@@ -1260,6 +1260,10 @@ func phase7Schema(db *DB.DB, empT *DB.Table[Emp], empCore *core.Table) {
 	// ---- 7.4 改列名 ----
 	sub("7.4 改列名 (引擎无 RENAME API, 只能 DROP 旧列 + ADD 新列)")
 	oldDept := dumpVal(row0["Dept"])
+	// 复活软删除的列必须沿用【原类型 + 原宽度码】: master registry 的类型/参数是所有历史
+	// 版本共用的行偏移依据 (见 getVersionedOffset / rowDataSizeForVersion), 换成别的类型/
+	// 宽度会让已落盘的旧行整段错位, 引擎会直接拒。所以先把原定义抄下来, 7.5 用它复活。
+	deptDef := empCore.Field[fieldIndexByName(empCore, "Dept")]
 	must(empCore.DropIndex("idx_emp_dept"))
 	must(empCore.DropField("Dept"))
 	must(empCore.AddField(types.FieldInfo{FieldType: types.FieldType_32byte, FieldName: []byte("DeptName")}))
@@ -1273,8 +1277,10 @@ func phase7Schema(db *DB.DB, empT *DB.Table[Emp], empCore *core.Table) {
 		"改列名只能 DROP 旧列 + ADD 新列; 老行原值不会被迁移到新列名(数据仍在行内, 只是列名不可达), 需要业务侧自行回填")
 
 	// ---- 7.5 复活同名列 ----
+	// 用 7.4 抄下来的原定义 (类型 + 宽度码) 复活; 只有"同名同类型"才允许复活,
+	// 想换宽度必须改用新列名 (见 Table.AddField 的复活分支)。
 	sub("7.5 重新加回同名 Dept 列 (DropField 实为软删除)")
-	must(empCore.AddField(types.FieldInfo{FieldType: types.FieldType_32byte, FieldName: []byte("Dept")}))
+	must(empCore.AddField(deptDef))
 	r = coreRow(empCore, "pk_emp", uint64(sampleNo))
 	got, okGot := r["Dept"]
 	chk("7.5 同名列复活后老行数据可见", okGot, "可见", fmt.Sprintf("可见=%v", okGot))

+ 165 - 103
demo/lock_table_test/main.go

@@ -4,7 +4,6 @@ import (
 	"TsDB2/DB"
 	"fmt"
 	"os"
-	"sync"
 	"time"
 )
 
@@ -13,7 +12,23 @@ type Record struct {
 	Name string `vrtx:"maxlen:32"`
 }
 
-const dbPath = "lock_table_test.db"
+const (
+	dbPath   = "lock_table_test.db"
+	poolPath = "lock_test2.db"
+)
+
+// failed 累计失败项。demo 用退出码说话: 只打印一句 "⚠️ 未检测到阻塞" 然后正常退出,
+// 等于把"没验到"当成"验过了" —— 这正是老版本测试2/3 的样子。
+var failed int
+
+func check(ok bool, format string, args ...any) {
+	if ok {
+		fmt.Printf("  ✅ "+format+"\n", args...)
+		return
+	}
+	failed++
+	fmt.Printf("  ❌ "+format+"\n", args...)
+}
 
 func main() {
 	fmt.Println("==============================================")
@@ -21,147 +36,194 @@ func main() {
 	fmt.Print("==============================================\n\n")
 
 	os.Remove(dbPath)
-
 	db, err := DB.Open(dbPath)
 	if err != nil {
 		panic(err)
 	}
-	defer db.Close()
-
 	tbl, err := DB.UseTable[Record](db, "test")
 	if err != nil {
 		panic(err)
 	}
 
-	// ========== 测试1: 手动锁表 ==========
+	testManualLock(tbl, db)
+	testAutoCommitOnThreshold(tbl, db)
+	testBufferPoolFull()
+	testReopenDurability(tbl, db)
+}
+
+// testManualLock 钉住 README 注意事项 7 的语义: LockWrites 挡住的是【页落盘】,
+// 不是 Insert 本身 —— 锁定期插入被阻塞但不报错, 解锁后一条不少地进去。
+//
+// 老版本这里断言的是"锁定期插入应当被拒绝并返回错误"。那是错的: 引擎里根本没有
+// "写入被拒绝"这种错误码, BufferPool.WritePage 走的是阻塞等待 (waitForWriteLock)。
+// 于是插入在解锁后正常返回 err == nil, 断言"应为错误"必然成立不了 —— demo 一直是红的,
+// 而 README 又写着"全部通过"。
+func testManualLock(tbl *DB.Table[Record], db *DB.DB) {
 	fmt.Println("测试1: 手动 LockWrites/UnlockWrites")
+	before, err := tbl.Count()
+	if err != nil {
+		check(false, "前置查询行数: %v", err)
+		return
+	}
 
 	db.LockWrites()
 	fmt.Println("  ✅ 已锁定写入")
 
-	// 尝试在另一个 goroutine 中插入(应该阻塞)
-	var wg sync.WaitGroup
-	insertSuccess := false
-
-	wg.Add(1)
+	type result struct {
+		err     error
+		elapsed time.Duration
+	}
+	rec := &Record{Name: "Emp0000001"}
+	done := make(chan result, 1)
 	go func() {
-		defer wg.Done()
-		err := tbl.Insert(&Record{Name: "Emp0000001"})
-		if err == nil {
-			insertSuccess = true
-			fmt.Println("  ❌ 锁定期不应允许插入")
-		} else {
-			fmt.Printf("  ✅ 插入被阻止: %v\n", err)
-		}
+		start := time.Now()
+		insertErr := tbl.Insert(rec)
+		done <- result{err: insertErr, elapsed: time.Since(start)}
 	}()
 
-	// 等待 100ms,确认 goroutine 已阻塞
-	time.Sleep(100 * time.Millisecond)
-	fmt.Println("  ⏳ 插入操作正在等待解锁...")
+	// 用"有没有结果"判断阻塞, 而不是用耗时阈值 —— 阈值只能证明"慢", 证明不了"被挡住"。
+	var res result
+	blocked := true
+	select {
+	case res = <-done:
+		blocked = false
+	case <-time.After(100 * time.Millisecond):
+	}
+
+	check(blocked, "锁定期插入被阻塞在页落盘处")
 
-	// 解锁
-	time.Sleep(200 * time.Millisecond)
 	db.UnlockWrites()
 	fmt.Println("  ✅ 已解锁写入")
-
-	wg.Wait()
-	if insertSuccess {
-		fmt.Println("  ❌ 测试失败: 锁定期插入了数据")
-	} else {
-		fmt.Println("  ✅ 测试通过: 锁定期成功阻止写入")
+	if blocked {
+		// 被挡住的那一支还没收到结果, 等它跑完再验 —— 否则下面查的是"插入之前"的行数。
+		res = <-done
 	}
 
-	// ========== 测试2: Commit 期间自动锁表 ==========
-	fmt.Println("\n测试2: Commit 期间自动锁表")
+	check(res.err == nil, "锁定期写入不报错 (err=%v, 解锁后 %v 完成)", res.err, res.elapsed)
 
-	// 插入一些数据
-	for i := 1; i <= 100; i++ {
-		tbl.Insert(&Record{Name: fmt.Sprintf("Emp%07d", i)})
-	}
-	fmt.Println("  已插入 100 行")
+	after, err := tbl.Count()
+	check(err == nil && after == before+1, "解锁后真的落盘: %d → %d 行", before, after)
 
-	// 在另一个 goroutine 中尝试插入
-	wg.Add(1)
-	commitBlocked := false
+	if rec.ID != 0 {
+		got, findErr := tbl.Find(rec.ID)
+		check(findErr == nil && got != nil, "能按引擎分配到的 _id=%d 读回", rec.ID)
+	} else {
+		check(false, "插入后结构体没有回填 _id, 无法验证读回")
+	}
+}
 
-	go func() {
-		defer wg.Done()
-		start := time.Now()
-		err := tbl.Insert(&Record{Name: "Emp0000101"})
-		elapsed := time.Since(start)
+// testAutoCommitOnThreshold 钉住真正的写入路径: 脏页阈值在 WritePage 里【同步】触发
+// Commit (pool.WritePage 在锁外直接调 onThreshold), 也就是"写到一半顺手提交一次"。
+// SetCommitPreset 的每一档都靠它, 200 行配 5 页阈值必然触发很多次。
+//
+// 为什么不用"一边写一边在另一个 goroutine 里 Commit": 引擎是【单写入者模型】
+// (见 pool/buffer_pool.go 里关于 capacityWait 死锁的注释), 写入与 Commit 必须由同一个
+// 写入者先后调用。跨 goroutine 重叠会踩到一个真问题: CollectDirty 把脏页移出缓存之后、
+// 回写磁盘之前, 这一页既不在缓存里也还不在盘上, 并发的读会从盘上读到全零页 ——
+// 实测稳定 panic (btPID: slice bounds out of range [-4:], 页头 EntryLen 读到 0)。
+// 那条路要等引擎补上写入串行化才能测。
+func testAutoCommitOnThreshold(tbl *DB.Table[Record], db *DB.DB) {
+	fmt.Println("\n测试2: 写入过程中脏页阈值触发自动提交")
+	db.SetAutoCommitDelay(0) // 关掉防抖计时器, 只留阈值触发, 保证提交是同步发生的
+	db.SetDirtyThreshold(5)  // 5 个脏页提交一次, 下面的行数必然触发多次
+
+	before, err := tbl.Count()
+	if err != nil {
+		check(false, "前置查询行数: %v", err)
+		return
+	}
 
-		if elapsed > 10*time.Millisecond {
-			commitBlocked = true
-			fmt.Printf("  ✅ 插入被 Commit 阻塞 (%v)\n", elapsed)
+	const rows = 200
+	for i := 0; i < rows; i++ {
+		if insertErr := tbl.Insert(&Record{Name: fmt.Sprintf("Auto%06d", i)}); insertErr != nil {
+			check(false, "写入第 %d 行时失败: %v", i+1, insertErr)
+			return
 		}
-		_ = err
-	}()
-
-	// 等待 goroutine 启动
-	time.Sleep(50 * time.Millisecond)
-
-	// 执行 Commit(会自动锁表)
-	fmt.Println("  执行 Commit...")
-	db.Commit()
-	fmt.Println("  ✅ Commit 完成")
-
-	wg.Wait()
-
-	if commitBlocked {
-		fmt.Println("  ✅ 测试通过: Commit 期间成功阻止写入")
-	} else {
-		fmt.Println("  ⚠️  未检测到阻塞(可能 Commit 太快)")
 	}
 
-	// ========== 测试3: BufferPool 满时阻塞等待 ==========
-	fmt.Println("\n测试3: BufferPool 满时阻塞等待")
-
-	// 设置很小的容量 (10页)
-	db2, _ := DB.Open("lock_test2.db")
-	defer db2.Close()
-	os.Remove("lock_test2.db")
+	after, err := tbl.Count()
+	check(err == nil && after == before+rows, "穿插 %d 次阈值提交后一行不丢: %d → %d",
+		rows/5, before, after)
+}
 
-	db2.SetBufferPoolCapacity(10) // 只允许10页
-	tbl2, _ := DB.UseTable[Record](db2, "test")
+// testBufferPoolFull 钉住 README 注意事项 7 的第三条: 池子满时淘汰干净页而不是死锁。
+//
+// 老版本同样只看耗时 (> 50ms), 既证明不了"淘汰了干净页", 也证明不了"不死锁"—— 真死锁
+// 了它只会永远挂住。这里改成两条硬断言: 写入必须在时限内跑完 (超时即失败, 把"挂住"
+// 变成"报错"), 并且淘汰之后每一行都还得读得回来 (淘汰逻辑不能吃掉数据)。
+func testBufferPoolFull() {
+	fmt.Println("\n测试3: BufferPool 满时淘汰干净页 (容量 10 页)")
+	os.Remove(poolPath)
+	db2, err := DB.Open(poolPath)
+	if err != nil {
+		check(false, "打开 %s: %v", poolPath, err)
+		return
+	}
+	defer func() {
+		db2.Close()
+		os.Remove(poolPath)
+	}()
 
-	// 插入10行(填满 BufferPool)
-	for i := 1; i <= 10; i++ {
-		tbl2.Insert(&Record{Name: fmt.Sprintf("Emp%07d", i)})
+	db2.SetBufferPoolCapacity(10) // 只允许 10 页
+	tbl2, err := DB.UseTable[Record](db2, "test")
+	if err != nil {
+		check(false, "建表: %v", err)
+		return
 	}
-	fmt.Println("  已插入 10 行(BufferPool 已满)")
 
-	// 在另一个 goroutine 中尝试插入第11行(应该阻塞)
-	var insertBlocked bool
-	wg.Add(1)
+	const rows = 200
+	done := make(chan error, 1)
 	go func() {
-		defer wg.Done()
-		start := time.Now()
-		tbl2.Insert(&Record{Name: "Emp0000011"})
-		elapsed := time.Since(start)
-
-		if elapsed > 50*time.Millisecond {
-			insertBlocked = true
-			fmt.Printf("  ✅ 插入被阻塞 (%v),等待空间释放\n", elapsed)
+		for i := 1; i <= rows; i++ {
+			if insertErr := tbl2.Insert(&Record{Name: fmt.Sprintf("Emp%07d", i)}); insertErr != nil {
+				done <- insertErr
+				return
+			}
 		}
+		done <- nil
 	}()
 
-	// 等待 goroutine 启动并阻塞
-	time.Sleep(100 * time.Millisecond)
+	// 容量 10 页远小于 200 行占的页数, 插入必然要边走边淘汰; 给它一个上限,
+	// 拿不到结果就是"没能淘汰干净页把自己憋死了"。
+	select {
+	case err := <-done:
+		check(err == nil, "容量 10 页写入 %d 行无错误 (err=%v)", rows, err)
+	case <-time.After(30 * time.Second):
+		check(false, "写入超时未完成: 池子满时没有淘汰干净页 (疑似死锁)")
+		return
+	}
 
-	// 执行 Commit,释放空间
-	fmt.Println("  执行 Commit 释放空间...")
-	db2.Commit()
-	fmt.Println("  ✅ Commit 完成,空间已释放")
+	n, err := tbl2.Count()
+	check(err == nil && int(n) == rows, "淘汰后行数完整: 期望 %d, 实际 %d (err=%v)", rows, n, err)
+}
 
-	wg.Wait()
+// testReopenDurability 关掉再开: 上面的写入走的是 WAL 提交路径, 重开时要按 WAL 恢复
+// 回来, 一行都不能少。顺带覆盖"Close 会兜底把脏页落盘"这条。
+func testReopenDurability(tbl *DB.Table[Record], db *DB.DB) {
+	fmt.Println("\n测试4: 关闭重开后数据仍在 (WAL 恢复)")
 
-	if insertBlocked {
-		fmt.Println("  ✅ 测试通过: BufferPool 满时正确阻塞等待")
-	} else {
-		fmt.Println("  ⚠️  未检测到阻塞(可能 Commit 太快)")
+	before, err := tbl.Count()
+	if err != nil {
+		check(false, "关闭前查行数: %v", err)
+		return
 	}
+	check(db.Close() == nil, "关闭数据库")
 
-	fmt.Println("\n==============================================")
-	fmt.Println("  所有测试完成!")
-	fmt.Println("==============================================")
+	reopened, err := DB.Open(dbPath)
+	if err != nil {
+		check(false, "重开 %s: %v", dbPath, err)
+		return
+	}
+	defer func() {
+		reopened.Close()
+		os.Remove(dbPath)
+	}()
+
+	tbl2, err := DB.UseTable[Record](reopened, "test")
+	if err != nil {
+		check(false, "重开表: %v", err)
+		return
+	}
+	after, err := tbl2.Count()
+	check(err == nil && after == before, "重开后行数一致: %d → %d (err=%v)", before, after, err)
 }

+ 1 - 1
docs/INDEX_TYPES_AND_ORM_GUIDE.md

@@ -85,7 +85,7 @@ type Product struct {
 | `vrtx:"index:x,type:hash"` | Hash 二级索引 | `6 Hash` |
 | `vrtx:"index:x"`(默认) | B+ 二级索引 | `5 BTree` |
 | `vrtx:"unique"`(可与前者连写,如 `index:x,unique`) | **唯一索引**:同一索引键只允许一行,重复插入 / 更新报 `core.ErrDuplicatedKey`;`NULL` 不参与(可共存多行 `NULL`) | 随所声明的索引类型 |
-| `vrtx:"maxlen:N"` | 变长字符串字段(非索引) | — |
+| `vrtx:"maxlen:N"` | 变长字符串字段(非索引);`N` ≤ 16360(单值上限,超了 `ParseModel` 报错) | — |
 
 
 列类型 tag(不写就按 Go 类型推断):

+ 1 - 1
docs/OPERATION_TUTORIAL.md

@@ -65,7 +65,7 @@ type Order struct {
 **字段声明规则:**
 - **基类(必须)**:每个表结构体都要内嵌 `DB.BaseModel` / `DB.PrimaryModel`(主键表,`_id`)或 `DB.TimeSeriesModel`(时序表,`_ts`)。基类既是"这是一张本框架的表"的标记,也提供对象头部的增删改查(`Insert / Update / Delete / Save / Load / Reload / PK`)。没有内嵌基类的结构体不是本框架的表:`ParseModel` / `UseTable` 直接报 `DB.ErrNotModel`(校验在建表之前,库里不会留下半成品表)。
 - **数值类型**:无需 tag,按 Go 类型自动映射。有符号整数单独占一档(`Int8`/`Int16`/`Int32`/`Int64`,盘上用保序编码),无符号整数走无符号档(`1`/`2`/`4`/`8byte`):`int8` → `Int8`、`int16` → `Int16`、`int32` → `Int32`、`int64`/`int` → `Int64`;`uint8`/`bool` → `1byte`、`uint16` → `2byte`、`uint32` → `4byte`、`uint64` → `8byte`。两档盘上字节不同、不可混用,否则范围查询 / 排序会出错。
-- **字符串**:用 `vrtx:"maxlen:N"` 指定最大字节数,映射到最接近的定长类型(≤32→32byte,≤64→64byte,≤128→128byte,≤256→256byte,>256→变长)。
+- **字符串**:用 `vrtx:"maxlen:N"` 指定最大字节数,映射到最接近的定长类型(≤32→32byte,≤64→64byte,≤128→128byte,≤256→256byte,>256→变长)。`N` 的上界是 **16360 字节**:超过 2048 字节的列转动态列(内容整体存进一个堆页),声明超过这个数的列在 `ParseModel` 阶段直接报错 —— 那种容量兑不了现,与其等写入时才失败,不如建模时就拒。
 - **索引**:在 tag 中追加 `index:名称`(默认 B+ 树)或 `index:名称,type:hash`(Hash 桶式索引)。
 - **主键**:`vrtx:"pk"` 把基类默认的键列(`_id` / `_ts`)**覆盖**成自己的字段(`_id` / `_ts` 不再建列),字段类型必须是 `int` / `uint` / `int64` / `uint64`;字符串、浮点、`int32` 等窄整数会在 `ParseModel` 时报 `DB.ErrInvalidPrimaryKey`。注意 `pk` 标签不能替代基类 —— 只写 `pk` 而不内嵌基类同样报 `DB.ErrNotModel`。
 - 一个 tag 内多个声明用逗号分隔,例如 `vrtx:"maxlen:32,index:idx_name"`。

+ 156 - 0
migrate/abort_test.go

@@ -0,0 +1,156 @@
+package migrate
+
+import (
+	"TsDB2/DB/core/page"
+	"TsDB2/DB/core/types"
+	"database/sql"
+	"errors"
+	"path/filepath"
+	"testing"
+)
+
+// failSource 一个"扫描到一半就报错"的源。
+//
+// 前 failAt 行正常吐出来, 第 failAt+1 行直接返回错误 —— 用来把"扫描失败时目标端
+// 到底提交了什么"钉死。failAt < 0 表示永不失败 (当对照组: 正常路径必须照常提交)。
+type failSource struct {
+	table  string
+	sc     *TableSchema
+	rows   int
+	failAt int
+}
+
+func (s *failSource) Kind() Kind       { return KindSQLite } // 借 SQL 的字面量即可, 只用于日志/报告
+func (s *failSource) Identity() string { return "fail-source" }
+func (s *failSource) Close() error     { return nil }
+
+func (s *failSource) Tables() ([]string, error) { return []string{s.table}, nil }
+
+func (s *failSource) Schema(string) (*TableSchema, error) { return s.sc.Clone(), nil }
+
+func (s *failSource) Scan(_ string, _ *TableSchema, fn func(Row) error) error {
+	for i := 0; i < s.rows; i++ {
+		if i == s.failAt {
+			return errors.New("模拟扫描中途失败")
+		}
+		if err := fn(Row{
+			PK:     uint64(i + 1),
+			HasPK:  true,
+			Values: []any{int64(i + 1), int64(i * 10)},
+		}); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// TestScanFailureDoesNotCommitPartialRows 扫描失败时, 缓冲区里那批行必须被丢弃。
+//
+// 修复前 runTable 在出错路径上调的是 writer.Close() —— 而 SQL 写端的 Close 是
+// 【成功语义】: flush 剩余缓冲 + Commit。于是攒在 RowWriter 缓冲里、还没写出去的行被
+// 当成成功提交上去, 调用方却拿到一个 error、以为这张表没进去 —— 库里躺了半张表,
+// 而且这份"半截数据"下一次续迁还会撞主键。
+//
+// 修复后出错路径走 Discarder.Discard (Rollback), 只有真正提交过的批次留在库里。
+func TestScanFailureDoesNotCommitPartialRows(t *testing.T) {
+	cases := []struct {
+		name     string
+		rows     int
+		failAt   int
+		wantRows int
+	}{
+		{"扫到第 3 行失败", 5, 2, 0},
+		{"扫到第 5 行失败", 5, 4, 0},
+		{"对照组: 正常迁完", 5, -1, 5},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			dir := t.TempDir()
+			dstPath := filepath.Join(dir, "dst.sqlite")
+
+			dst, err := OpenSQLite(dstPath, quiet())
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			// 只留两列整数: 避开字符串宽度档位这些与本次验证无关的东西。
+			sc := &TableSchema{
+				Name: "t1",
+				Columns: []Column{
+					{Name: "id", Type: types.FieldType_Int64},
+					{Name: "n", Type: types.FieldType_Int64},
+				},
+				PKColumn:    "id",
+				PKIndexType: page.IndexTypePrimary,
+			}
+			src := &failSource{table: "t1", sc: sc, rows: c.rows, failAt: c.failAt}
+
+			// 批大小比行数大得多, 于是所有行都只会留在写入器的缓冲里 ——
+			// 这正是"出错时 Close 会把它们提交上去"的那一批。
+			o := quiet()
+			_, runErr := Run(src, dst, o)
+			if err := dst.Close(); err != nil {
+				t.Fatal(err)
+			}
+
+			if c.failAt >= 0 && runErr == nil {
+				t.Fatal("扫描失败必须把错误抛给调用方")
+			}
+			if c.failAt < 0 && runErr != nil {
+				t.Fatalf("对照组不该失败: %v", runErr)
+			}
+
+			// 关键断言: 库里到底留了几行。
+			db, err := sql.Open("sqlite", dstPath)
+			if err != nil {
+				t.Fatal(err)
+			}
+			defer db.Close()
+
+			var n int
+			if err := db.QueryRow(`SELECT COUNT(*) FROM t1`).Scan(&n); err != nil {
+				t.Fatalf("查行数: %v", err)
+			}
+			if n != c.wantRows {
+				t.Fatalf("目标表应当留下 %d 行, 实际 %d 行; Run 错误: %v", c.wantRows, n, runErr)
+			}
+		})
+	}
+}
+
+// TestAbortWriterPrefersDiscard 盯住 runTable 出错路径的选择: 实现了 Discarder 就
+// 走 Discard, 没实现才退回 Close。VRTX 写端没实现它 (它的 Close 不提交), 所以两条
+// 路都必须留着 —— 少了哪一支, 要么 SQL 侧提交半截数据, 要么 VRTX 侧收尾漏掉。
+func TestAbortWriterPrefersDiscard(t *testing.T) {
+	d := &discardProbe{}
+	abortWriter(d)
+	if !d.discarded {
+		t.Fatal("实现了 Discarder 的写入器必须走 Discard")
+	}
+	if d.closed {
+		t.Fatal("走了 Discard 就不该再走 Close")
+	}
+
+	c := &closeOnlyProbe{}
+	abortWriter(c)
+	if !c.closed {
+		t.Fatal("没实现 Discarder 的写入器必须退回 Close")
+	}
+}
+
+type discardProbe struct {
+	discarded bool
+	closed    bool
+}
+
+func (p *discardProbe) Write([]Row) error { return nil }
+func (p *discardProbe) Close() error      { p.closed = true; return nil }
+func (p *discardProbe) Discard() error    { p.discarded = true; return nil }
+
+type closeOnlyProbe struct {
+	closed bool
+}
+
+func (p *closeOnlyProbe) Write([]Row) error { return nil }
+func (p *closeOnlyProbe) Close() error      { p.closed = true; return nil }

+ 54 - 15
migrate/mysql.go

@@ -143,20 +143,46 @@ func (d mysqlDial) listTables(db *sql.DB) ([]string, error) {
 // resource_info.resAlias 是 varchar(50), 值 73 字节, 整表迁移失败)。
 // CHARACTER_OCTET_LENGTH 正是"这列最多多少字节", 而且按该列自己的字符集算
 // (latin1 仍是 1 字节/字符), 所以直接拿它当容量, 既够用又不至于无谓放大。
-func mysqlColumnDecl(dataType string, charBytes, numPrec, numScale int64) string {
+//
+// unsigned 必须单独传进来, 因为无符号这件事【不在 DATA_TYPE 里】: MySQL 对
+// "bigint unsigned" 报的 DATA_TYPE 就是 "bigint", 只有 COLUMN_TYPE
+// ("bigint(20) unsigned") 保留 unsigned; 而 inferSQLType 正是靠声明原文里有没有
+// "UNSIGNED" 来判无符号档的。少了这个后缀, 所有 MySQL 无符号列都会被当有符号收:
+// BIGINT UNSIGNED 的 2^63 读出来是负数, INT UNSIGNED 的上界只剩一半。
+func mysqlColumnDecl(dataType string, unsigned bool, charBytes, numPrec, numScale int64) string {
+	decl := dataType
 	switch {
 	case numScale > 0 || dataType == "decimal":
 		// DECIMAL 必须带上 (p,s): 只给 "decimal" 会按 scale=0 收, 小数位被抹平。
-		return fmt.Sprintf("%s(%d,%d)", dataType, numPrec, numScale)
+		decl = fmt.Sprintf("%s(%d,%d)", dataType, numPrec, numScale)
 	case charBytes > 0:
-		return fmt.Sprintf("%s(%d)", dataType, charBytes)
+		decl = fmt.Sprintf("%s(%d)", dataType, charBytes)
+	}
+	if unsigned {
+		// 后缀放在最后: inferSQLType 用 strings.Contains 找 "UNSIGNED", 位置无所谓;
+		// splitTypeArgs 只取第一个 "(" 里的参数, 后缀不会污染 (p,s) / (n)。
+		decl += " unsigned"
 	}
-	return dataType
+	return decl
+}
+
+// mysqlUnsigned 判断 COLUMN_TYPE 是不是无符号列。
+//
+// 只看 COLUMN_TYPE: DATA_TYPE 把 unsigned 丢了; EXTRA 只在显式写了 zerofill 时才
+// 出现 "zerofill", 而 zerofill 本身也隐含 unsigned —— COLUMN_TYPE 两种都覆盖
+// ("int(10) unsigned" / "int(10) unsigned zerofill")。
+func mysqlUnsigned(columnType string) bool {
+	return strings.Contains(strings.ToLower(columnType), "unsigned")
 }
 
-// columns 读列 + 单列主键名。复合主键返回空主键名 (VRTX 的行主键只能单列)。
-func (d mysqlDial) columns(db *sql.DB, table string) ([]Column, string, error) {
-	rows, err := db.Query(`SELECT COLUMN_NAME, DATA_TYPE,
+// columns 读列 + 单列主键名。
+//
+// 主键的那点信息这里最全, 所以两件"VRTX 承载不了"的事在这里记下 (见 sourceNotes):
+//   - 复合主键: VRTX 的行存储索引只支持单列。以前直接把主键名丢成空串, 下游只会报
+//     "源表没有主键"—— 而源表明明有主键, 只是多列, 提示是假的。
+//   - 列级的容量/精度差异: 只有这里看得到 information_schema 给的声明原文。
+func (d mysqlDial) columns(db *sql.DB, table string) ([]Column, string, sourceNotes, error) {
+	rows, err := db.Query(`SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE,
 			COALESCE(CHARACTER_OCTET_LENGTH, 0),
 			COALESCE(NUMERIC_PRECISION, 0), COALESCE(NUMERIC_SCALE, 0),
 			COLUMN_KEY, IS_NULLABLE
@@ -164,19 +190,28 @@ func (d mysqlDial) columns(db *sql.DB, table string) ([]Column, string, error) {
 		WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
 		ORDER BY ORDINAL_POSITION`, table)
 	if err != nil {
-		return nil, "", err
+		return nil, "", sourceNotes{}, err
 	}
 	defer rows.Close()
 
 	var cols []Column
 	var pkCols []string
+	var notes sourceNotes
 	for rows.Next() {
-		var name, dataType, key, nullable string
+		// COLUMN_TYPE 只用来判无符号 (DATA_TYPE 把它丢了), 其余长度信息仍走各自那一列。
+		var name, dataType, columnType, key, nullable string
 		var charBytes, numPrec, numScale int64
-		if err := rows.Scan(&name, &dataType, &charBytes, &numPrec, &numScale, &key, &nullable); err != nil {
-			return nil, "", err
+		if err := rows.Scan(&name, &dataType, &columnType, &charBytes, &numPrec, &numScale, &key, &nullable); err != nil {
+			return nil, "", sourceNotes{}, err
+		}
+		decl := mysqlColumnDecl(dataType, mysqlUnsigned(columnType), charBytes, numPrec, numScale)
+		ft, param, note := inferSQLTypeWithNote(decl, true)
+		if note != "" {
+			notes.Capped = append(notes.Capped, name+": "+note)
+		}
+		if d := degradeNote(decl); d != "" {
+			notes.Degraded = append(notes.Degraded, name+": "+d)
 		}
-		ft, param := inferSQLType(mysqlColumnDecl(dataType, charBytes, numPrec, numScale), true)
 		cols = append(cols, Column{
 			Name:     name,
 			Type:     ft,
@@ -189,12 +224,16 @@ func (d mysqlDial) columns(db *sql.DB, table string) ([]Column, string, error) {
 		}
 	}
 	if err := rows.Err(); err != nil {
-		return nil, "", err
+		return nil, "", sourceNotes{}, err
 	}
 	if len(pkCols) == 1 {
-		return cols, pkCols[0], nil
+		return cols, pkCols[0], notes, nil
+	}
+	if len(pkCols) > 1 {
+		notes.PK = compositePKNote(table, pkCols)
 	}
-	return cols, "", nil
+	// 复合主键 / 无主键: 都当作"没有单列主键", 由调用方决定补 _id 还是报错。
+	return cols, "", notes, nil
 }
 
 // indexes 读二级索引, 唯一性取 NON_UNIQUE=0。

+ 69 - 0
migrate/roundtrip_test.go

@@ -1173,3 +1173,72 @@ func TestSQLiteUniqueConstraintToVRTX(t *testing.T) {
 		}
 	}
 }
+
+// TestCappedColumnCapacityReported 源库声明了超过单值上限的列必须写进迁移报告。
+//
+// 与 SkippedIndexes 是同一条原则: 静默把容量改小等于悄悄改结构 —— 源库写的是
+// varchar(60000), 到 VRTX 端只能是条装不下 60000 字节的动态列, 不报出来用户永远不会知道。
+//
+// 但它与"丢索引"一样【不该让整张表陪葬】: 超限只说明这一列的上限比声明的小, 大部分数据照样
+// 迁得动, 真有大值会在写入时报出列名并拒 —— 为了一列的容量放弃整张表的数据, 代价不成比例。
+func TestCappedColumnCapacityReported(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "src.sqlite")
+	dstPath := filepath.Join(dir, "dst.db")
+
+	raw, err := sql.Open("sqlite", srcPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	// SQLite 不校验 varchar 的长度, 所以"声明了引擎兑现不了的容量"这种库在现实中真会出现。
+	stmts := []string{
+		`CREATE TABLE Wide (
+			id  INTEGER PRIMARY KEY,
+			big VARCHAR(60000),
+			ok  VARCHAR(200)
+		)`,
+		`INSERT INTO Wide (id, big, ok) VALUES (1, 'small', 'fine')`,
+	}
+	for _, s := range stmts {
+		if _, err := raw.Exec(s); err != nil {
+			t.Fatalf("建样本 SQLite 库: %v", err)
+		}
+	}
+	if err := raw.Close(); err != nil {
+		t.Fatal(err)
+	}
+
+	src, err := OpenSQLite(srcPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer src.Close()
+	dst, err := OpenVRTX(dstPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer dst.Close()
+
+	rep, err := Run(src, dst, quiet())
+	if err != nil {
+		t.Fatalf("迁移不该因为容量封顶而失败: %v", err)
+	}
+	if len(rep.Tables) != 1 {
+		t.Fatalf("应当只有 1 张表的报告, 实际 %d", len(rep.Tables))
+	}
+	note := rep.Tables[0].Note
+	// 报告要点名到列, 并给出两个数: 用户写下的 60000 与引擎能给的上限。
+	for _, want := range []string{"容量封顶", "big", "60000", strconv.Itoa(types.MaxStringSize)} {
+		if !strings.Contains(note, want) {
+			t.Errorf("报告里缺少 %q: %q", want, note)
+		}
+	}
+	// 没超限的列不该被连坐 (ok 列只有 200 字节, 在定长档里)。
+	if strings.Contains(note, "ok:") {
+		t.Errorf("未超限的列不该出现在容量报告里: %q", note)
+	}
+	// 数据照迁: 容量封顶只是提示, 不是拒绝。
+	if rep.Rows != 1 {
+		t.Errorf("应照常搬走 1 行, 实际 %d", rep.Rows)
+	}
+}

+ 63 - 2
migrate/run.go

@@ -125,6 +125,21 @@ func planTables(src Source, o *Options) ([]string, []TableReport, error) {
 // progressEvery 进行中进度的上报间隔: 太密会把日志刷没, 太疏又看不出"在动"。
 const progressEvery = 2 * time.Second
 
+// abortWriter 出错时收掉写入器。
+//
+// 走 Discarder 而不是 Close: Close 是【成功语义】(SQL 侧 flush 剩余缓冲 + Commit),
+// 用它收尾等于把缓冲区里那批行当成功提交。扫描失败 / flush 失败的调用方都带着错误
+// 返回, 库里却躺了半张表 —— 这种"报了错但还是写进去了"最难查。
+// 没实现 Discarder 的写入器 (VRTX) 退回 Close: 它的 Close 不提交, 提交由 Committer
+// 在整表成功后单独调用, 因此没有这个问题。
+func abortWriter(w RowWriter) {
+	if d, ok := w.(Discarder); ok {
+		_ = d.Discard()
+		return
+	}
+	_ = w.Close()
+}
+
 // runTable 迁一张表: 读结构 → (必要时) 建表 → 推流写入 → 提交。
 // 第二个返回值是这张表的报告 (即使出错也带着已写行数, 便于定位)。
 func runTable(src Source, dst Sink, name string, o *Options) (TableReport, error) {
@@ -141,6 +156,23 @@ func runTable(src Source, dst Sink, name string, o *Options) (TableReport, error
 		if err := prepareForVRTX(sc, o); err != nil {
 			return tr, err
 		}
+		// 结构闸门必须在建表【之前】: 源库的结构能力比 VRTX 宽 (MySQL 1017 列 / 表名 64 字节 /
+		// 复合主键…), 不先查一遍就会在建表时才炸, 报错还看不出是"源库比目标库宽"
+		// (见 validateForVRTX)。放在 prepareForVRTX 之后是因为补出的 _id 列也算列数。
+		if err := validateForVRTX(sc); err != nil {
+			return tr, err
+		}
+		// 行宽闸门 (同样必须在建表之前, 且要在列清单定形之后): 源库那张表可能宽到 VRTX
+		// 一页装不下两行 —— 那是建出来之后【每一行】都写不进去的死局, 不能等写数据才发现。
+		// 能自动救的就地救 (宽度 > 256 字节的定长字符串列 → 动态列), 救不了才拒绝,
+		// 见 fitRowWidthForVRTX 的文件头。
+		demoted, err := fitRowWidthForVRTX(sc)
+		if err != nil {
+			return tr, err
+		}
+		if demoted > 0 {
+			o.vlogf("[migrate] 表 %s 行宽超限: 已把 %d 个宽列降为动态列", sc.Name, demoted)
+		}
 	}
 	tr.Columns = len(sc.Columns)
 	tr.Indexes = len(sc.Indexes)
@@ -173,6 +205,22 @@ func runTable(src Source, dst Sink, name string, o *Options) (TableReport, error
 		// 跳过的索引必须报出来: 静默丢索引等于悄悄改了结构。
 		tr.Note = appendNote(tr.Note, "跳过索引 "+strings.Join(sc.SkippedIndexes, "; "))
 	}
+	if sc.PKNote != "" {
+		// 源表的主键没能原样承载 (复合主键)。这是"结构变了", 比丢一条索引更要紧,
+		// 单独报一句而不是塞进别的列表里。
+		tr.Note = appendNote(tr.Note, sc.PKNote)
+	}
+	if len(sc.DegradedColumns) > 0 {
+		// 类型从定点退成浮点、主键降级成普通列这类: 类型都换了, 存进去的东西语义不同。
+		// 与容量封顶分开报 —— 那个是"上限变小, 类型没变"。
+		tr.Note = appendNote(tr.Note, "降级 "+strings.Join(sc.DegradedColumns, "; "))
+	}
+	if len(sc.CappedColumns) > 0 {
+		// 同理: 源库声明了 60000 字节的列到这里只能装 types.MaxStringSize, 不报等于
+		// 悄悄把容量改小。超限只影响"这一列的上限比声明的小", 数据照迁 (真有大值会在
+		// 写入时报出列名), 所以是报告而不是拒绝整张表。
+		tr.Note = appendNote(tr.Note, "容量封顶 "+strings.Join(sc.CappedColumns, "; "))
+	}
 	if o.SchemaOnly {
 		// 两阶段迁移的第一步: 到这儿结构就算立好了, 数据一行不碰。
 		tr.Note = appendNote(tr.Note, "仅结构")
@@ -222,12 +270,12 @@ func runTable(src Source, dst Sink, name string, o *Options) (TableReport, error
 		return nil
 	})
 	if scanErr != nil && !errors.Is(scanErr, errStop) {
-		_ = writer.Close()
+		abortWriter(writer)
 		tr.Rows = written
 		return tr, fmt.Errorf("扫描表 %s: %w", name, scanErr)
 	}
 	if err := flush(); err != nil {
-		_ = writer.Close()
+		abortWriter(writer)
 		tr.Rows = written
 		return tr, err
 	}
@@ -270,6 +318,9 @@ func appendNote(note, add string) string {
 //   - 主键是字符串/其它宽度 → 补一个 _id 列承载行号, 原主键列退化成普通列
 //     (顺手给它建个 B+ 树索引, 找回"按原主键查"的能力);
 //   - 压根没有主键 → 只有 AutoPK 打开才补 _id, 否则报错 (VRTX 不接受没有行主键的表)。
+//
+// 后两条都是【改结构】, 所以会往 sc.DegradedColumns 记一笔由 runTable 报给用户;
+// 复合主键 (源表有主键但承载不了) 则记在 sc.PKNote 上, 报错时也用它说准话。
 func prepareForVRTX(sc *TableSchema, o *Options) error {
 	if len(sc.RowStoreColumns) > 0 {
 		// 时序表: 行存储索引建在时间列上 (RowStore), 行号由引擎分配, 不占列。
@@ -282,6 +333,11 @@ func prepareForVRTX(sc *TableSchema, o *Options) error {
 			return nil
 		}
 	} else if !o.AutoPK {
+		if sc.PKNote != "" {
+			// 源表【有】主键, 只是多列 —— 报"没有主键"是假消息, 用户会去源库找不存在的问题。
+			return fmt.Errorf("表 %s: %s —— 请改用单列 8 字节整数主键, "+
+				"或开 AutoPK 让它自动补 %s 列", sc.Name, sc.PKNote, "_id")
+		}
 		return fmt.Errorf("表 %s 没有主键, 目标 VRTX 的表必须有行主键列 —— "+
 			"请给源表加 8 字节整数主键, 或开 AutoPK 让它自动补 %s 列", sc.Name, "_id")
 	}
@@ -299,6 +355,11 @@ func prepareForVRTX(sc *TableSchema, o *Options) error {
 	}
 	// 原主键列不再是行主键, 但"按它查"是源库的既有用法, 用二级索引续上。
 	ensureColumnIndex(sc, origPK)
+	// 这一步是【约束降级】: 源库的主键唯一性由主键约束强制, 而补出来的二级索引不是唯一
+	// 索引。静默丢掉一个约束比报错更糟 —— 用户会以为主键还在, 继续依赖它。
+	sc.DegradedColumns = append(sc.DegradedColumns, fmt.Sprintf(
+		"主键 %s 承载不了行号 (不是 8 字节整数列): 已改用引擎分配的 %s 列, "+
+			"原主键列降为普通列 + 普通索引 (源库的主键唯一性不再强制)", origPK, idName))
 	return nil
 }
 

+ 40 - 9
migrate/sql.go

@@ -53,8 +53,12 @@ type dialect interface {
 
 	// listTables 列出用户表 (不含内部表)。
 	listTables(db *sql.DB) ([]string, error)
-	// columns 读列 (按表内顺序) 与主键列名。
-	columns(db *sql.DB, table string) ([]Column, string, error)
+	// columns 读列 (按表内顺序)、主键列名, 以及"源库的结构 VRTX 承载不了"的说明。
+	//
+	// 第三个返回值是读端才看得见的东西 (只有这里拿得到 SQL 的声明原文与主键构成),
+	// 由 SQL.Schema 落到 TableSchema 的 CappedColumns / DegradedColumns / PKNote 上,
+	// 最终进迁移报告 —— 见 sourceNotes。
+	columns(db *sql.DB, table string) ([]Column, string, sourceNotes, error)
 	// indexes 读二级索引 (不含主键索引)。
 	indexes(db *sql.DB, table string) ([]IndexSchema, error)
 
@@ -122,7 +126,7 @@ func (s *SQL) Tables() ([]string, error) {
 
 // Schema 读一张表的结构: SQL 声明 → VRTX 类型, 再用保真表 (若有) 覆盖回来。
 func (s *SQL) Schema(table string) (*TableSchema, error) {
-	cols, pk, err := s.dial.columns(s.db, table)
+	cols, pk, notes, err := s.dial.columns(s.db, table)
 	if err != nil {
 		return nil, fmt.Errorf("读 %s 表 %s 的结构: %w", s.kind, table, err)
 	}
@@ -130,10 +134,13 @@ func (s *SQL) Schema(table string) (*TableSchema, error) {
 		return nil, fmt.Errorf("%s 表 %s 不存在或没有列", s.kind, table)
 	}
 	schema := &TableSchema{
-		Name:        table,
-		Columns:     cols,
-		PKColumn:    pk,
-		PKIndexType: page.IndexTypePrimary,
+		Name:            table,
+		Columns:         cols,
+		PKColumn:        pk,
+		PKIndexType:     page.IndexTypePrimary,
+		CappedColumns:   notes.Capped,
+		DegradedColumns: notes.Degraded,
+		PKNote:          notes.PK,
 	}
 	if idx, err := s.dial.indexes(s.db, table); err == nil {
 		schema.Indexes = idx
@@ -266,7 +273,8 @@ func (s *SQL) CreateTable(sc *TableSchema, recreate bool) error {
 // ensureColumns 复用已有表时补上缺的列 (SQL 的 ADD COLUMN), 免得 INSERT 撞上未知列。
 // VRTX 侧的 schema 同步是同一套思路: 结构向"源"看齐, 但不删目标已有的列。
 func (s *SQL) ensureColumns(sc *TableSchema) error {
-	cols, _, err := s.dial.columns(s.db, sc.Name)
+	// 容量说明只在读整表结构时报一次 (这里是补列的辅助路径, 丢掉即可)。
+	cols, _, _, err := s.dial.columns(s.db, sc.Name)
 	if err != nil {
 		return err
 	}
@@ -359,7 +367,7 @@ func (w *sqlWriter) Write(rows []Row) error {
 			if i < len(r.Values) {
 				raw = r.Values[i]
 			}
-			v, err := coerceForSQL(c, raw)
+			v, err := coerceForSQL(c, raw, w.s.kind == KindMySQL)
 			if err != nil {
 				return err
 			}
@@ -438,6 +446,29 @@ func (w *sqlWriter) Close() error {
 	return nil
 }
 
+// Discard 放弃尚未提交的内容 (migrate.Discarder, 出错路径用)。
+//
+// 与 Close 的区别只有收尾那一步: Close 会把缓冲区 flush 掉再 Commit, Discard 直接
+// Rollback 并丢掉缓冲。已经跨过 commitEvery 阈值提交过的批次留在库里 —— 那是分批
+// 提交的既有语义 (长事务会撑爆 undo/日志), 能挽回的是当前事务和缓冲区里这一批;
+// 报告里的 Rows 会如实说明写到哪儿了。
+func (w *sqlWriter) Discard() error {
+	if w.closed {
+		return nil
+	}
+	w.closed = true
+	for _, s := range w.stmts {
+		_ = s.Close()
+	}
+	w.stmts = map[int]*sql.Stmt{}
+	w.pending = w.pending[:0]
+	w.pendingRows = 0
+	if err := w.tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) {
+		return fmt.Errorf("回滚 %s 表 %s: %w", w.s.kind, w.table, err)
+	}
+	return nil
+}
+
 // ============================ 保真表 ============================
 
 // metaDTO 保真表里一行 (JSON) 的内容。字段名特意取短: 这张表会跟着数据一起躺在

+ 29 - 9
migrate/sqlite.go

@@ -87,10 +87,11 @@ func (d sqliteDial) listTables(db *sql.DB) ([]string, error) {
 }
 
 // columns 读列与主键列名 (PRAGMA table_info; pk 字段是该列在主键里的序号)。
-func (d sqliteDial) columns(db *sql.DB, table string) ([]Column, string, error) {
+// 第三个返回值是"声明容量超过单值上限"的列 (见 capacityNote), 由 SQL.Schema 收进报告。
+func (d sqliteDial) columns(db *sql.DB, table string) ([]Column, string, sourceNotes, error) {
 	rows, err := db.Query("PRAGMA table_info(" + d.quote(table) + ")")
 	if err != nil {
-		return nil, "", err
+		return nil, "", sourceNotes{}, err
 	}
 	defer rows.Close()
 
@@ -99,8 +100,9 @@ func (d sqliteDial) columns(db *sql.DB, table string) ([]Column, string, error)
 		name string
 	}
 	var (
-		cols []Column
-		pks  []pkCol
+		cols  []Column
+		pks   []pkCol
+		notes sourceNotes
 	)
 	for rows.Next() {
 		var (
@@ -109,9 +111,15 @@ func (d sqliteDial) columns(db *sql.DB, table string) ([]Column, string, error)
 			dflt             any
 		)
 		if err := rows.Scan(&cid, &name, &decl, &notNull, &dflt, &pk); err != nil {
-			return nil, "", err
+			return nil, "", sourceNotes{}, err
+		}
+		ft, param, note := inferSQLTypeWithNote(decl, false)
+		if note != "" {
+			notes.Capped = append(notes.Capped, name+": "+note)
+		}
+		if d := degradeNote(decl); d != "" {
+			notes.Degraded = append(notes.Degraded, name+": "+d)
 		}
-		ft, param := inferSQLType(decl, false)
 		cols = append(cols, Column{
 			Name:     name,
 			Type:     ft,
@@ -124,7 +132,7 @@ func (d sqliteDial) columns(db *sql.DB, table string) ([]Column, string, error)
 		}
 	}
 	if err := rows.Err(); err != nil {
-		return nil, "", err
+		return nil, "", sourceNotes{}, err
 	}
 	if len(pks) == 1 {
 		// SQLite 的 `id INTEGER PRIMARY KEY` 是 rowid 别名, PRAGMA 里 notnull = 0
@@ -137,10 +145,22 @@ func (d sqliteDial) columns(db *sql.DB, table string) ([]Column, string, error)
 				cols[i].Nullable = false
 			}
 		}
-		return cols, pks[0].name, nil
+		return cols, pks[0].name, notes, nil
+	}
+	if len(pks) > 1 {
+		// `PRIMARY KEY (a, b)` 是 SQLite 的表级约束, PRAGMA 里表现为多列的 pk 序号。
+		// 与 MySQL 同名场景一样: VRTX 的行存储索引只能单列, 但"源表主键是哪几列"这个
+		// 事实只有这里知道, 记下来供下游报准话。
+		ordered := make([]string, len(pks))
+		for _, p := range pks {
+			if p.seq >= 1 && p.seq <= len(pks) {
+				ordered[p.seq-1] = p.name
+			}
+		}
+		notes.PK = compositePKNote(table, ordered)
 	}
 	// 复合主键 / 无主键: 都当作"没有单列主键", 由调用方决定自增还是报错
-	return cols, "", nil
+	return cols, "", notes, nil
 }
 
 // indexes 读二级索引, 唯一性照搬 (PRAGMA index_list 的 unique 列)。

+ 127 - 26
migrate/sqltype.go

@@ -2,6 +2,7 @@ package migrate
 
 import (
 	"TsDB2/DB/core/types"
+	"fmt"
 	"strconv"
 	"strings"
 )
@@ -20,7 +21,7 @@ import (
 //	BOOLEAN / BOOL             Bool
 //	YEAR                       Year      (2 字节年份, 0..32767)
 //	UUID                       UUID      (16 字节 RFC4122)
-//	CHAR(n) / VARCHAR(n)       按容量 n 就近取 16/32/64/128/256byte 档
+//	CHAR(n) / VARCHAR(n)       按字节容量取最近的宽度档 (16..2048, 超过则 dynamic)
 //	TEXT / JSON                dynamic   (字节原样存, 不解释内容)
 //	BLOB / BINARY              blob      (同为动态列, 但语义是"文件/二进制大对象")
 //	DATE / DATETIME / TIMESTAMP 8byte    (Unix 纳秒时间戳, 见 inferSQLType 的说明)
@@ -31,33 +32,125 @@ import (
 //
 // 这里的 n 是【字节容量】不是字符数 —— VRTX 的定长档按字节算容量, 而 SQL 的长度单位是
 // 字符, 两者只有在单字节字符集下才重合。MySQL 侧直接读 CHARACTER_OCTET_LENGTH
-// (varchar(50) 在 utf8mb4 下就是 200 字节 → 256byte 档); SQLite 的长度声明本就不强制,
+// (varchar(50) 在 utf8mb4 下就是 200 字节 → 224 字节档); SQLite 的长度声明本就不强制,
 // 仍按声明里的 n 算 (见 inferSQLType)。按字符数取档会把列开窄: 一个 25 字的中文别名
-// 就有 75 字节, 塞进 64byte 只能报错或截断。
+// 就有 75 字节, 塞进 72 字节档只能报错。
 //
-// 反过来 (VRTX → SQL) 用 sqlTypeName, 定长档声明成 CHAR/VARCHAR(n)。因为目标方言按字符
-// 计数 (utf8mb4 下 VARCHAR(64) 实际能装 256 字节), 再读回来会宽一档 —— 也就是【不带
-// __vrtx_meta 时定长字符列会逐轮变宽】(数据不丢, 只是档位漂移)。要原样往返就带 meta,
-// 而 meta 是默认开着的。
+// 档位由 types.WidthCodeFor 算出 (向上取, 最坏多占 25%), 并写进列的 Flag 低 5 位:
+// 于是"这一列多宽"随 schema 落盘, 不再依赖类型枚举。varchar(1000) 这种旧设计里表达
+// 不出的列 (256 档会截断, dynamic 又不能建索引) 现在能原样落成 1024 字节的定长档。
+//
+// 反过来 (VRTX → SQL) 用 sqlTypeName, 定长档声明成 VARCHAR(该列的真实字节宽度)。因为
+// 目标方言按字符计数 (utf8mb4 下 VARCHAR(64) 实际能装 256 字节), 再读回来会宽一档
+// —— 也就是【不带 __vrtx_meta 时定长字符列会逐轮变宽】(数据不丢, 只是档位漂移)。
+// 要原样往返就带 meta, 而 meta 是默认开着的。
+//
+// 【声明容量的上界】落动态列 (超过 MaxInlineColumnWidth) 不等于"声明多大都行": 动态列的
+// 内容整体落在一个堆页里, 单值上限是 types.MaxStringSize。源库里 varchar(60000) 这种
+// 声明在这里【兑现不了】, 而丢掉声明值换成 dynamic 是"悄悄把容量改小"—— 所以读端会把
+// 它记进迁移报告 (见 capacityNote)。这条闸门只对"声明了长度的字符/二进制列"生效:
+// TEXT / BLOB 家族的长度是类型自身的上界而非用户声明, 不该按它报警。
 
-// bucketType 按容量取定长档位 (>256 字节没有定长档 → 变长列)。
-func bucketType(size int) types.FieldType {
+// bucketColumn 按字节容量取定长档位, 返回 (类型, 列级宽度码)。
+//
+// 宽度码写进 types.FieldInfo.Flag 低 5 位, 是这一列行内宽度的唯一依据 (见 types.FieldSize);
+// 返回的 FieldType 只决定回显名与"没有宽度码时"的默认宽度。
+//
+// 超过 types.MaxInlineColumnWidth (2048) 的列只能落变长档: 定长档的存在意义是"可索引、
+// 可定长比较", 变长列两者都没有 (引擎建索引时会明确拒绝)。
+func bucketColumn(size int) (types.FieldType, byte) {
+	code := types.WidthCodeFor(size)
+	if code == 0 {
+		return types.FieldType_Dynamicbyte, 0
+	}
+	ft := types.FieldType_256byte
 	switch {
-	case size <= 0:
-		return types.FieldType_Dynamicbyte
 	case size <= 16:
-		return types.FieldType_16byte
+		ft = types.FieldType_16byte
 	case size <= 32:
-		return types.FieldType_32byte
+		ft = types.FieldType_32byte
 	case size <= 64:
-		return types.FieldType_64byte
+		ft = types.FieldType_64byte
 	case size <= 128:
-		return types.FieldType_128byte
-	case size <= 256:
-		return types.FieldType_256byte
+		ft = types.FieldType_128byte
+	}
+	return ft, code
+}
+
+// inferSQLTypeWithNote 与 inferSQLType 同源, 额外报告"这一列声明的容量兑现不了"。
+//
+// 为什么另立一个入口而不是给它加第三个返回值: 类型推断有二十来个分支, 每个分支都得写一个
+// 恒为空的第三个返回值, 噪声比信息多; 而"容量能不能兑现"只有读源结构 (拿得到声明原文) 时
+// 才关心, 于是把它挂在入口上, 由读端显式选用 —— 想要这句话的人用这个函数, 只关心类型的
+// 人继续用 inferSQLType。
+func inferSQLTypeWithNote(decl string, mysql bool) (types.FieldType, byte, string) {
+	ft, param := inferSQLType(decl, mysql)
+	return ft, param, capacityNote(decl)
+}
+
+// capacityNote 报告这一列的声明容量被引擎单值上限 (types.MaxStringSize) 封顶; 未超限返回 ""。
+//
+// 只对【声明了长度的字符 / 二进制列】报, 刻意不含 TEXT / BLOB 家族 —— 它们的 decl 里那个
+// 长度不是用户的声明: MySQL 的 information_schema 会把 "text" 报成
+// CHARACTER_OCTET_LENGTH = 65535 (mysqlColumnDecl 于是写成 "text(65535)"), 拿它去报
+// "声明兑现不了"是误报。那类列本来就没有容量声明, 引擎的单值上限是已知限制, 由写入侧
+// 兜底 (写入超限会明确拒绝并报出列名), 不在这里重复报。
+func capacityNote(decl string) string {
+	base, args := splitTypeArgs(strings.ToUpper(strings.TrimSpace(decl)))
+	switch base {
+	case "CHAR", "CHARACTER", "VARCHAR", "NCHAR", "NVARCHAR", "VARCHAR2",
+		"BINARY", "VARBINARY":
 	default:
-		return types.FieldType_Dynamicbyte
+		return ""
+	}
+	if len(args) == 0 {
+		return ""
+	}
+	// 错误文本复用 types 里那一份: 两个方向 (ORM 的 maxlen / 迁移的 SQL 长度) 的容量闸门
+	// 是同一个约束, 提示语也该是同一句, 否则用户会以为是两回事。
+	if err := types.CheckDeclaredStringSize(args[0]); err != nil {
+		return err.Error()
+	}
+	return ""
+}
+
+// degradeNote 报告这一列的类型在 VRTX 端只能【降级收编】—— 与 capacityNote 是两回事:
+//   - capacityNote: 类型不变, 只是上限比声明的小 (装不下那么多);
+//   - degradeNote:  类型变了, 语义跟着变 (存的东西是另一回事)。
+//
+// 目前只有一种: DECIMAL(p,s) 超出定点列的表达范围时 inferSQLType 退回 float64。退回本身
+// 是对的 (总比假装精确好), 但它【有损】, 必须报出来 —— 用户以为迁过来的是定点数, 实际拿到
+// 的是浮点数: 0.1+0.2 不再等于 0.3, 大额值丢有效位, 聚合结果对不上账。
+//
+// 与容量报告同一条原则: 报出来只影响提示, 不改变类型 (类型由 inferSQLType 单独决定,
+// 两边必须逐字节一致, 否则"报了一句"就悄悄改了列的类型, 比不报更糟)。
+func degradeNote(decl string) string {
+	base, args := splitTypeArgs(strings.ToUpper(strings.TrimSpace(decl)))
+	if base != "DECIMAL" && base != "NUMERIC" {
+		return ""
+	}
+	if len(args) == 0 {
+		return ""
+	}
+	prec, scale := 38, 0
+	if len(args) >= 1 {
+		prec = args[0]
+	}
+	if len(args) >= 2 {
+		scale = args[1]
+	}
+	// 与 inferSQLType 的判定条件必须一致 —— 这里说"降级了"而那边其实没降 (或反过来),
+	// 报告就成了假消息。
+	if prec <= types.MaxDecimalPrecision && scale >= 0 && scale <= types.MaxDecimalScale {
+		return ""
+	}
+	// 指名是哪一条超了: 精度与小数位是两个上限 (38 / 31), 只报"超限"用户不知道该改哪个。
+	which := fmt.Sprintf("精度 %d 超过上限 %d", prec, types.MaxDecimalPrecision)
+	if scale > types.MaxDecimalScale {
+		which = fmt.Sprintf("小数位 %d 超过上限 %d", scale, types.MaxDecimalScale)
 	}
+	return fmt.Sprintf("DECIMAL(%d,%d) 无法精确表达 (%s), 已退化成 float64: "+
+		"需要精确比较/聚合的列会丢精度", prec, scale, which)
 }
 
 // inferSQLType 由 SQL 列类型 (含长度) 推断 VRTX 类型, 返回 (类型, 列参数)。
@@ -110,8 +203,10 @@ func inferSQLType(decl string, mysql bool) (types.FieldType, byte) {
 		return types.FieldType_Float64, 0
 	case "DECIMAL", "NUMERIC":
 		// DECIMAL(p,s) 收成定点 Decimal(s) —— 不像以前折成 float 那样丢精度。
-		// 上限 38 位是 128 位系数的容量: p 或 s 超出就没法精确表达, 退回 Float64
-		// (与改动前一致), 至少不会假装精确。
+		// p 的上界是 38 (128 位系数的十进制容量, types.MaxDecimalPrecision),
+		// s 的上界是 31 (Flag 低 5 位, types.MaxDecimalScale), 两个是【不同】的约束 ——
+		// 以前拿 MaxDecimalScale(31) 去卡 p, 于是 DECIMAL(38,2) 这种完全表达得了的列
+		// 被误判成"表达不了"而退成 float64: 精度不是被能力限制, 是被写错的上限限制的。
 		prec, scale := 38, 0
 		if len(args) >= 1 {
 			prec = args[0]
@@ -119,9 +214,10 @@ func inferSQLType(decl string, mysql bool) (types.FieldType, byte) {
 		if len(args) >= 2 {
 			scale = args[1]
 		}
-		if prec <= types.MaxDecimalScale && scale >= 0 && scale <= types.MaxDecimalScale {
+		if prec <= types.MaxDecimalPrecision && scale >= 0 && scale <= types.MaxDecimalScale {
 			return types.FieldType_Decimal, byte(scale)
 		}
+		// 真的表达不了才退 Float64 (至少不假装精确) —— 降级本身要报出来, 见 degradeNote。
 		return types.FieldType_Float64, 0
 	case "BOOLEAN", "BOOL":
 		// 独立出 Bool 类型: 以前借用 1byte, "写 2 也算真" 让等值/索引给出多套答案。
@@ -133,7 +229,7 @@ func inferSQLType(decl string, mysql bool) (types.FieldType, byte) {
 		return types.FieldType_UUID, 0
 	case "CHAR", "CHARACTER", "VARCHAR", "NCHAR", "NVARCHAR", "VARCHAR2":
 		if size > 0 {
-			return bucketType(size), 0
+			return bucketColumn(size)
 		}
 		return types.FieldType_32byte, 0
 	case "TEXT", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", "CLOB", "NCLOB":
@@ -202,7 +298,7 @@ func splitTypeAndSize(u string) (string, int) {
 
 // sqlTypeName VRTX 类型 → SQL 声明。
 //
-//	param: 列级类型参数 (Decimal 的 scale), 其余类型忽略
+//	param: 列级类型参数 —— Decimal 是 scale, 字符串家族是行内宽度码, 其余类型忽略
 //	mysql: false 用 VARCHAR(n)/BLOB 这套 (SQLite), true 用 MySQL 的写法
 func sqlTypeName(t types.FieldType, param byte, mysql bool) string {
 	switch t {
@@ -255,9 +351,12 @@ func sqlTypeName(t types.FieldType, param byte, mysql bool) string {
 		}
 		return "REAL"
 	case types.FieldType_Decimal:
-		// 38 是 128 位系数的十进制容量 (与 types.MaxDecimalScale 一致)。
+		// p 取 MaxDecimalPrecision(38), 不是 MaxDecimalScale(31): 声明必须能装下 VRTX 里
+		// 任何一个 Decimal 值。若按 (31,s) 声明, scale=30 的列就只剩 1 位整数位, 而 VRTX
+		// 的系数上限是 10^38-1 —— 导出 8 位整数的值时会被 MySQL 判成超范围而拒绝。
+		// 声明宽了不会丢东西 (迁移是"引擎能给多宽就声明多宽"), 声明窄了才丢。
 		if mysql {
-			return "DECIMAL(" + strconv.Itoa(int(types.MaxDecimalScale)) + "," + strconv.Itoa(int(param)) + ")"
+			return "DECIMAL(" + strconv.Itoa(types.MaxDecimalPrecision) + "," + strconv.Itoa(int(param)) + ")"
 		}
 		// SQLite 的 NUMERIC 亲和性会把 "1.50" 转成 REAL 1.5, 丢掉 scale 与高位精度;
 		// 用 TEXT 原样存定点文本, 回来再按列 scale 解析。
@@ -288,7 +387,9 @@ func sqlTypeName(t types.FieldType, param byte, mysql bool) string {
 		return "BLOB"
 	case types.FieldType_16byte, types.FieldType_32byte, types.FieldType_64byte,
 		types.FieldType_128byte, types.FieldType_256byte:
-		return "VARCHAR(" + strconv.Itoa(int(types.GetSize(t))) + ")"
+		// 声明该列的【真实】字节宽度: 宽度码 (maxlen:1000 → 1024) 与类型默认档
+		// (256byte → 256) 是两回事, 只按类型声明会把宽度码声明出来的列开窄。
+		return "VARCHAR(" + strconv.Itoa(int(types.FieldSize(t, param))) + ")"
 	default:
 		// Dynamicbyte (变长文本/二进制) 落到 BLOB/LONGBLOB 而不是 TEXT:
 		//   - 它本来就可能是任意二进制 (绑 []byte 出去), 声明成 TEXT 在 MySQL 下

+ 188 - 24
migrate/sqltype_test.go

@@ -2,6 +2,9 @@ package migrate
 
 import (
 	"TsDB2/DB/core/types"
+	"fmt"
+	"strconv"
+	"strings"
 	"testing"
 )
 
@@ -43,8 +46,16 @@ func TestInferSQLType(t *testing.T) {
 		{"DECIMAL(18,2)", true, types.FieldType_Decimal, 2},
 		{"DECIMAL(10,0)", true, types.FieldType_Decimal, 0},
 		{"NUMERIC(20,6)", false, types.FieldType_Decimal, 6},
-		// 超出 128 位系数容量 → 退回 Float64 (与改动前一致), 不假装精确
+		// p 与 s 是【两个不同的上界】: p ≤ 38 (128 位系数的十进制容量),
+		// s ≤ 31 (Flag 低 5 位)。以前拿 31 去卡 p, 于是 DECIMAL(38,2) 被误判成
+		// "表达不了"而退成 float64 —— 那是被写错的上限限制的, 不是被能力限制的。
+		{"DECIMAL(38,2)", true, types.FieldType_Decimal, 2},
+		{"DECIMAL(38,31)", true, types.FieldType_Decimal, 31},
+		// 真的超出系数容量 → 退回 Float64 (与改动前一致), 不假装精确; 但要报出来 (degradeNote)
 		{"DECIMAL(65,30)", true, types.FieldType_Float64, 0},
+		{"DECIMAL(39,0)", true, types.FieldType_Float64, 0},
+		// s 超 31: 系数装得下, 但小数位无处表达
+		{"DECIMAL(38,35)", true, types.FieldType_Float64, 0},
 
 		// 其余新类型
 		{"BOOLEAN", false, types.FieldType_Bool, 0},
@@ -61,10 +72,15 @@ func TestInferSQLType(t *testing.T) {
 		{"TEXT", false, types.FieldType_Dynamicbyte, 0},
 		{"LONGTEXT", true, types.FieldType_Dynamicbyte, 0},
 
-		// 定长串按长度就近取档
-		{"VARCHAR(8)", true, types.FieldType_16byte, 0},
-		{"VARCHAR(200)", true, types.FieldType_256byte, 0},
-		{"CHAR(32)", true, types.FieldType_32byte, 0},
+		// 定长串: 按字节容量【向上】取宽度档, 档位号写进列级参数 (Flag 低 5 位)。
+		// 返回的 param 不再是 0 —— 它就是"这一列多宽"的载体 (见 types.FieldSize)。
+		{"VARCHAR(8)", true, types.FieldType_16byte, types.WidthCodeFor(8)},
+		{"VARCHAR(200)", true, types.FieldType_256byte, types.WidthCodeFor(200)},
+		{"CHAR(32)", true, types.FieldType_32byte, types.WidthCodeFor(32)},
+		// 超过旧的 256 字节天花板仍可以定长: 2048 以内都保得住"可索引"
+		{"VARCHAR(1000)", true, types.FieldType_256byte, types.WidthCodeFor(1000)},
+		// 只有超过 MaxInlineColumnWidth 才落变长列
+		{"VARCHAR(4000)", true, types.FieldType_Dynamicbyte, 0},
 
 		// 时间列落 8byte (Unix 纳秒), 不是动态列
 		{"DATETIME", true, types.FieldType_8byte, 0},
@@ -94,17 +110,22 @@ func TestMySQLColumnDecl(t *testing.T) {
 		want                         types.FieldType
 		wantParam                    byte
 	}{
-		// varchar(50) utf8mb4 → octet 200 → 256byte (老算法 50 → 64byte, 装不下中文)
-		{"utf8mb4 varchar(50)", "varchar", 200, 0, 0, types.FieldType_256byte, 0},
-		// varchar(12) utf8mb4 → octet 48 → 64byte
-		{"utf8mb4 varchar(12)", "varchar", 48, 0, 0, types.FieldType_64byte, 0},
-		// 单字节字符集不背 4 倍: latin1 的 varchar(50) 就是 50 字节
-		{"latin1 varchar(50)", "varchar", 50, 0, 0, types.FieldType_64byte, 0},
-		// 正好卡在档位边界上: varchar(64) utf8mb4 = 256 字节, 用满最后一档
-		{"utf8mb4 varchar(64)", "varchar", 256, 0, 0, types.FieldType_256byte, 0},
-		// 再多一个字符就超出所有定长档: 宁可变长也不开窄列 (变长只是没索引, 不会丢数据)
-		{"utf8mb4 varchar(65)", "varchar", 260, 0, 0, types.FieldType_Dynamicbyte, 0},
-		{"utf8mb4 char(1)", "char", 4, 0, 0, types.FieldType_16byte, 0},
+		// varchar(50) utf8mb4 → octet 200 → 224 字节档 (老算法 50 → 64byte 装不下中文;
+		// 中间的过渡算法给 256 档, 现在能按声明精确到 224)
+		{"utf8mb4 varchar(50)", "varchar", 200, 0, 0, types.FieldType_256byte, types.WidthCodeFor(200)},
+		// varchar(12) utf8mb4 → octet 48 → 48 字节档
+		{"utf8mb4 varchar(12)", "varchar", 48, 0, 0, types.FieldType_64byte, types.WidthCodeFor(48)},
+		// 单字节字符集不背 4 倍: latin1 的 varchar(50) 就是 50 字节 (取 64 档)
+		{"latin1 varchar(50)", "varchar", 50, 0, 0, types.FieldType_64byte, types.WidthCodeFor(50)},
+		// 正好卡在旧天花板上: varchar(64) utf8mb4 = 256 字节
+		{"utf8mb4 varchar(64)", "varchar", 256, 0, 0, types.FieldType_256byte, types.WidthCodeFor(256)},
+		// 超过旧天花板不再落变长列: 320 字节仍在定长档内, 保住"可索引"
+		{"utf8mb4 varchar(65)", "varchar", 260, 0, 0, types.FieldType_256byte, types.WidthCodeFor(260)},
+		// 直到 MaxInlineColumnWidth (2048) 为止都还能定长
+		{"utf8mb4 varchar(512)", "varchar", 2048, 0, 0, types.FieldType_256byte, types.WidthCodeFor(2048)},
+		// 超出 2048 才落变长列 (变长只是没索引, 不会丢数据)
+		{"utf8mb4 varchar(600)", "varchar", 2400, 0, 0, types.FieldType_Dynamicbyte, 0},
+		{"utf8mb4 char(1)", "char", 4, 0, 0, types.FieldType_16byte, types.WidthCodeFor(4)},
 		{"utf8mb4 text", "text", 65535, 0, 0, types.FieldType_Dynamicbyte, 0},
 		{"blob", "blob", 65535, 0, 0, types.FieldType_Blob, 0},
 		// 定点: (p,s) 要读进来, 小数位不能被抹平
@@ -114,7 +135,7 @@ func TestMySQLColumnDecl(t *testing.T) {
 		{"datetime", "datetime", 0, 0, 0, types.FieldType_8byte, 0},
 	}
 	for _, c := range cases {
-		decl := mysqlColumnDecl(c.dataType, c.charBytes, c.numPrec, c.numScale)
+		decl := mysqlColumnDecl(c.dataType, false, c.charBytes, c.numPrec, c.numScale)
 		ft, param := inferSQLType(decl, true)
 		if ft != c.want || param != c.wantParam {
 			t.Errorf("%s: %q → (%v, %d), 期望 (%v, %d)",
@@ -123,13 +144,79 @@ func TestMySQLColumnDecl(t *testing.T) {
 	}
 }
 
+// TestMySQLUnsigned 无符号标志只能从 COLUMN_TYPE 读。
+//
+// MySQL 对 "bigint unsigned" 报的 DATA_TYPE 就是 "bigint" —— 只看 DATA_TYPE
+// 等于把无符号这件事整个丢掉, 于是无符号列全被当有符号收:
+//   - BIGINT UNSIGNED 的 2^63 读回来是负数;
+//   - INT UNSIGNED 的上界只剩一半。
+//
+// EXTRA 也靠不住 (只在显式 zerofill 时才有值), COLUMN_TYPE 两处都覆盖。
+func TestMySQLUnsigned(t *testing.T) {
+	cases := []struct {
+		columnType string
+		want       bool
+	}{
+		{"bigint(20) unsigned", true},
+		{"int(10) unsigned zerofill", true}, // zerofill 隐含 unsigned
+		{"bigint unsigned", true},
+		{"BIGINT UNSIGNED", true}, // information_schema 大小写随版本
+		{"bigint(20)", false},
+		{"int(11)", false},
+		{"varchar(50)", false},
+		{"decimal(18,2)", false},
+		{"datetime", false},
+	}
+	for _, c := range cases {
+		if got := mysqlUnsigned(c.columnType); got != c.want {
+			t.Errorf("mysqlUnsigned(%q) = %v, 期望 %v", c.columnType, got, c.want)
+		}
+	}
+}
+
+// TestMySQLColumnDeclUnsigned 无符号后缀必须真的走到 inferSQLType,
+// 而且不能污染第一个括号里的容量 / 精度参数。
+func TestMySQLColumnDeclUnsigned(t *testing.T) {
+	cases := []struct {
+		name                         string
+		dataType, columnType         string
+		charBytes, numPrec, numScale int64
+		want                         types.FieldType
+		wantParam                    byte
+	}{
+		// DATA_TYPE 丢了 unsigned, 补上 COLUMN_TYPE 才能收成无符号档
+		{"bigint unsigned", "bigint", "bigint(20) unsigned", 0, 20, 0, types.FieldType_8byte, 0},
+		{"bigint signed", "bigint", "bigint(20)", 0, 20, 0, types.FieldType_Int64, 0},
+		{"int unsigned", "int", "int(10) unsigned", 0, 10, 0, types.FieldType_4byte, 0},
+		{"int signed", "int", "int(11)", 0, 10, 0, types.FieldType_Int32, 0},
+		{"smallint unsigned", "smallint", "smallint(5) unsigned", 0, 5, 0, types.FieldType_2byte, 0},
+		{"tinyint unsigned", "tinyint", "tinyint(3) unsigned", 0, 3, 0, types.FieldType_1byte, 0},
+		// 后缀在最后, 不能把 (n) / (p,s) 吃掉
+		{"varchar(50) utf8mb4 unsigned", "varchar", "varchar(50) unsigned", 200, 0, 0,
+			types.FieldType_256byte, types.WidthCodeFor(200)},
+		{"decimal(18,2) unsigned", "decimal", "decimal(18,2) unsigned", 0, 18, 2,
+			types.FieldType_Decimal, 2},
+	}
+	for _, c := range cases {
+		decl := mysqlColumnDecl(c.dataType, mysqlUnsigned(c.columnType), c.charBytes, c.numPrec, c.numScale)
+		ft, param := inferSQLType(decl, true)
+		if ft != c.want || param != c.wantParam {
+			t.Errorf("%s: COLUMN_TYPE=%q → decl %q → (%v, %d), 期望 (%v, %d)",
+				c.name, c.columnType, decl, ft, param, c.want, c.wantParam)
+		}
+	}
+}
+
 // TestSQLTypeRoundTrip 声明出去的类型名必须能被自己读回同一个类型。
 //
 // 例外的几种都【不依赖 meta 就得靠宽度/语义兜底】, 这里显式列出来当文档:
 //   - 窄整数在 SQLite 只有 INTEGER 一种写法, 读回一律 Int64;
 //   - Decimal 在 SQLite 没有原生类型 (落 TEXT), 读回是 Dynamicbyte;
 //   - UUID 在 MySQL 用 BINARY(16) 存, 读回被认成 Blob;
-//   - Blob 与 Dynamicbyte 盘上同为动态列, 靠 meta 才能区分到具体哪一种。
+//   - Blob 与 Dynamicbyte 盘上同为动态列, 靠 meta 才能区分到具体哪一种;
+//   - 定长串的**宽度码不是逐位往返的**: 声明成 VARCHAR(列宽) 再读回来会按宽度重新取档,
+//     档位相同, 但"用类型默认档 (param 0)"的列会变成"写明档位 (param≠0)"。
+//     所以定长串钉的不变量是【字节宽度】(见下面第二个循环), 不是 param 字面值。
 //
 // 也就是说: 需要"精确类型"的往返都依赖 __vrtx_meta (默认开启); 没有 meta 时
 // 至少保证["数据不丢、语义不离谱"]。
@@ -160,12 +247,29 @@ func TestSQLTypeRoundTrip(t *testing.T) {
 				rt{types.FieldType_Decimal, 2},
 			)
 		}
-		// 定长串按长度取档, 档位本身就是 16/32/64/128/256, 所以能精确往返
-		for _, ft := range []types.FieldType{
-			types.FieldType_16byte, types.FieldType_32byte,
-			types.FieldType_64byte, types.FieldType_128byte, types.FieldType_256byte,
-		} {
-			exact = append(exact, rt{ft, 0})
+		// 定长串: 往返不变量是【字节宽度 + 类型族】。
+		//
+		// 声明成 VARCHAR(列宽) 之后再读回来会按宽度重新取档: 宽度码列往返后档位不变
+		// (类型也稳, 因为基底类型由宽度推出); 用类型默认档 (param 0) 的列则会拿到一个
+		// 显式宽度码 —— 宽度一样, 只是表示法从"默认档"变成"写明档位"。这正是本节
+		// 要钉住的性质: 变的只是表示, 不是行内布局。
+		stringCases := []rt{
+			{types.FieldType_16byte, 0}, {types.FieldType_32byte, 0},
+			{types.FieldType_64byte, 0}, {types.FieldType_128byte, 0},
+			{types.FieldType_256byte, 0},
+		}
+		for _, size := range []int{4, 16, 32, 48, 100, 200, 256, 300, 1000, 2048} {
+			ft, param := bucketColumn(size)
+			stringCases = append(stringCases, rt{ft, param})
+		}
+		for _, w := range stringCases {
+			decl := sqlTypeName(w.ft, w.param, mysql)
+			ft, param := inferSQLType(decl, mysql)
+			if ft != w.ft || types.FieldSize(ft, param) != types.FieldSize(w.ft, w.param) {
+				t.Errorf("mysql=%v: %v/param%d → %q → (%v, param%d): 类型或行内宽度变了 (%d → %d 字节)",
+					mysql, w.ft, w.param, decl, ft, param,
+					types.FieldSize(w.ft, w.param), types.FieldSize(ft, param))
+			}
 		}
 
 		for _, e := range exact {
@@ -178,3 +282,63 @@ func TestSQLTypeRoundTrip(t *testing.T) {
 		}
 	}
 }
+
+// TestDeclaredCapacityNote 声明容量超过单值上限的列必须【报出来】, 且不许误报。
+//
+// 落动态列不等于"声明多大都行": 动态列的内容整体落在一个堆页里, 单值上限是
+// types.MaxStringSize。源库里 varchar(60000) 这种声明在 VRTX 端兑现不了, 而读端是唯一看得到
+// 声明原文的地方 —— 在这里漏掉, 用户只会看到一条"容量比声明小得多"的列而没有任何提示。
+//
+// 反过来误报同样有害: TEXT / BLOB 家族在 MySQL 的 information_schema 里带着【类型自身的】
+// 上界 (text → CHARACTER_OCTET_LENGTH = 65535), 那个数不是用户的声明, 按它报"兑现不了"会让
+// 每一张带 TEXT 列的表都挂上一条无意义的警告, 真正的容量问题反而被淹掉。
+//
+// 容量报告只影响提示, 不影响类型: 同一个入口推出来的类型必须与 inferSQLType 逐字节一致,
+// 否则"报了一句"就悄悄改了列的类型, 比不报更糟。
+func TestDeclaredCapacityNote(t *testing.T) {
+	atCap := fmt.Sprintf("VARCHAR(%d)", types.MaxStringSize)
+	overCap := fmt.Sprintf("VARCHAR(%d)", types.MaxStringSize+1)
+
+	cases := []struct {
+		decl  string
+		mysql bool
+		ft    types.FieldType
+		param byte
+		note  bool // 是否应当报"容量封顶"
+	}{
+		// 还在定长档: 容量本来就远小于单值上限, 不报
+		{"VARCHAR(2048)", true, types.FieldType_256byte, types.WidthCodeFor(2048), false},
+		// 已落动态列但声明没超限: 声明多少就能装多少, 不报
+		{atCap, false, types.FieldType_Dynamicbyte, 0, false},
+		// 超过一个字节就兑现不了: 报
+		{overCap, false, types.FieldType_Dynamicbyte, 0, true},
+		{"VARCHAR(60000)", true, types.FieldType_Dynamicbyte, 0, true},
+		// 二进制长度族同理 (BINARY / VARBINARY 也带容量声明, 盘上同样是动态列)
+		{"BINARY(16)", true, types.FieldType_Blob, 0, false},
+		{"VARBINARY(60000)", true, types.FieldType_Blob, 0, true},
+		// TEXT / BLOB 家族: 括号里那个数是类型自身的上界, 不是用户的声明 → 不报
+		{"TEXT(65535)", true, types.FieldType_Dynamicbyte, 0, false},
+		{"LONGTEXT(4294967295)", true, types.FieldType_Dynamicbyte, 0, false},
+		{"LONGBLOB(4294967295)", true, types.FieldType_Blob, 0, false},
+		// 其它带括号的类型: 括号里是精度 / 小数位, 与容量无关
+		{"DECIMAL(18,2)", true, types.FieldType_Decimal, 2, false},
+	}
+	for _, c := range cases {
+		ft, param, note := inferSQLTypeWithNote(c.decl, c.mysql)
+		if ft != c.ft || param != c.param {
+			t.Errorf("inferSQLTypeWithNote(%q, mysql=%v) = (%v, %d), 期望 (%v, %d)",
+				c.decl, c.mysql, ft, param, c.ft, c.param)
+		}
+		if got := note != ""; got != c.note {
+			t.Errorf("%q: 报了容量=%v (%q), 期望 =%v", c.decl, got, note, c.note)
+		}
+	}
+
+	// 报出来的话要能直接定位问题: 用户写下的数与引擎能给的上限, 两个都得有。
+	_, _, note := inferSQLTypeWithNote(overCap, false)
+	for _, want := range []string{strconv.Itoa(types.MaxStringSize + 1), strconv.Itoa(types.MaxStringSize)} {
+		if !strings.Contains(note, want) {
+			t.Errorf("容量报告里缺少 %q: %q", want, note)
+		}
+	}
+}

+ 67 - 7
migrate/types.go

@@ -40,18 +40,38 @@ const (
 type Column struct {
 	Name string
 	Type types.FieldType
-	// Param 列级类型参数, 目前只有 Decimal 用 (小数位数 scale)。
+	// Param 列级类型参数, 按类型解释 (与 VRTX 的 Flag 低 5 位同源, 见 types.FieldSize):
 	//
-	// Decimal 的保序编码把 scale 写进了行内字节, "同一列 scale 必须一致"是它能被
-	// memcmp 比较的前提 —— 所以 scale 必须跟着列走 (__vrtx_meta 里也要存), 不能只
-	// 活在声明文本里, 否则导出再导入就丢了小数位。
+	//   - Decimal: 小数位数 scale。它的保序编码把 scale 写进了行内字节, "同一列 scale
+	//     必须一致"是它能被 memcmp 比较的前提 —— 所以 scale 必须跟着列走
+	//     (__vrtx_meta 里也要存), 不能只活在声明文本里, 否则导出再导入就丢了小数位。
+	//   - 字符串家族 (16/32/64/128/256byte 与变长): 行内宽度码。maxlen 声明出来的列真实
+	//     宽度由它决定, GetSize 只给类型默认档。
+	//   - 其余类型恒为 0 —— 由 ValidateColumnParam 强制。
 	Param byte
 	// TimeKind 这列在源 SQL 里的时间语义: "datetime" / "date" / "timestamp", 空即普通列。
 	//
 	// 为什么要单独记: VRTX 没有日期类型, 时间统一用 8 字节整数表达 (Unix 纳秒时间戳,
 	// 时序键 _ts 就是这么存的), 所以 SQL 的 DATETIME 落到 VRTX 就是一列 8byte ——
 	// 光看 FieldType 分不出它是"时间戳"还是"恰好取值很大的整数"。带着这个标记,
-	// 导回 SQL 时才能还原成 DATETIME 而不是 BIGINT (标记写进保真表 __vrtx_meta)。
+	// 导回 SQL 时才能还原成 DATETIME 而不是 BIGINT。
+	//
+	// 标记放在哪, 以及它管不到哪一段: 目前只写进 SQL 侧的保真表 __vrtx_meta,
+	// VRTX 的 schema 里【没有地方放它】——
+	//
+	//   - types.FieldInfo 的 Flag 一个字节已经排满: 低 5 位是宽度码 / scale,
+	//     5/6/7 位分别是 Unique / Nullable / Deleted;
+	//   - 借 Param 也不行, ValidateColumnParam 明确禁止非 Decimal、非字符串类型
+	//     带列级参数 ("8byte 的 Param 恒为 0"), 改这条就是把一个格式不变量拆了。
+	//
+	// 所以: SQL → VRTX → SQL 能还原成时间列 (标记跟着 __vrtx_meta 往返),
+	// 但【从 VRTX 直接导出】拿不到标记 —— 时间列会被声明成 BIGINT 而不是 DATETIME。
+	// 要补上这一段, 得给 VRTX 侧一个正经的存放位置 (加 FieldType / 内部元数据表),
+	// 那是一次格式版本决策, 不适合顺手做。
+	//
+	// 另外, 这个"退化成 BIGINT"其实比强转 DATETIME 更保守: 8byte 里存的是纳秒,
+	// 而 formatSQLTime 只能写到微秒 (MySQL DATETIME(6) 的上限), 转成时间文本会丢掉
+	// 后三位 —— 类型还原和精度无损目前只能二选一。
 	TimeKind string
 	// Nullable 该列允许 NULL (VRTX 的 FlagNullable ⇄ SQL 的可空声明)。
 	//
@@ -90,9 +110,12 @@ func (c Column) Flags() string {
 func (c Column) IsTime() bool { return c.TimeKind != "" }
 
 // Size 列在 VRTX 行内的字节宽度 (变长列为堆引用宽度)。
-func (c Column) Size() int { return int(types.GetSize(c.Type)) }
+//
+// 走 FieldSize(c.Type, c.Param): 字符串列的真实宽度由列级宽度码决定, GetSize 只给
+// 类型默认档 (对 maxlen 声明出来的列会算窄)。
+func (c Column) Size() int { return int(types.FieldSize(c.Type, c.Param)) }
 
-// IsDynamic 变长列 (>256 字节字符串 / 二进制)。
+// IsDynamic 变长列 (超过 MaxInlineColumnWidth 的字符串 / 二进制)。
 func (c Column) IsDynamic() bool { return types.IsDynamic(c.Type) }
 
 func (c Column) String() string {
@@ -254,6 +277,26 @@ type TableSchema struct {
 	// 动态列的堆引用, 不能拿它当索引键)。静默丢索引等于悄悄改结构, 所以记下来
 	// 报给用户 —— 而不是让整张表跟着失败。
 	SkippedIndexes []string
+	// CappedColumns 源库里声明了超过 VRTX 单值上限 (types.MaxStringSize) 的列,
+	// 形如 "Note: 声明容量 60000 字节超过单值上限 16360 字节 (...)"。
+	//
+	// 由读端填 (只有它拿得到 SQL 的声明原文, 见 capacityNote): 声明 varchar(60000)
+	// 的列落到 VRTX 只能是动态列, 而动态列装不下那么多 —— 声明兑现不了。这与
+	// SkippedIndexes 是同一条原则: 静默把容量改小等于悄悄改结构, 必须报出来,
+	// 但也不该让整张表的数据陪葬 (超限只是"这一列的上限比声明的小", 大部分数据照样迁得动)。
+	CappedColumns []string
+	// DegradedColumns 源库里能建、但到 VRTX 只能【降级表达】因而语义变了的列,
+	// 形如 "Amount: DECIMAL(65,30) 无法精确表达 (精度 65 超过上限 38), 已退化成 float64"。
+	//
+	// 与 CappedColumns 的分工: 那个是"类型不变、上限变小"; 这个是"类型都换了"。
+	// 同样由读端填 (只有它拿得到 SQL 的声明原文, 见 degradeNote), 同样只报不拒 ——
+	// 降级后的列照样装得下数据, 只是比较/聚合语义与原库不同 (定点 vs 浮点)。
+	DegradedColumns []string
+	// PKNote 源表有主键、但这个主键 VRTX 承载不了时的说明 (目前只有复合主键)。
+	//
+	// 读端在放弃 PKColumn 时必须填: "主键是哪几列"这个信息只存在于读端, 到了下游就只剩
+	// 一个空的 PKColumn —— 再报"源表没有主键"就是假消息 (源表明明有主键, 只是多列)。
+	PKNote string
 }
 
 // MarkSynthetic 标记一列是迁移过程补出来的。
@@ -302,6 +345,9 @@ func (s *TableSchema) Clone() *TableSchema {
 		}
 	}
 	c.SkippedIndexes = append([]string(nil), s.SkippedIndexes...)
+	c.CappedColumns = append([]string(nil), s.CappedColumns...)
+	c.DegradedColumns = append([]string(nil), s.DegradedColumns...)
+	c.PKNote = s.PKNote
 	return &c
 }
 
@@ -389,6 +435,20 @@ type RowWriter interface {
 	Close() error
 }
 
+// Discarder 写入器的可选接口: 放弃尚未提交的内容 (出错路径专用)。
+//
+// 为什么需要它: Close 是【成功语义】(SQL 侧 flush 剩余缓冲 + Commit)。扫描中途失败
+// 或 flush 出错时再用 Close 收尾, 等于把半截数据当成功提交 —— 调用方拿到错误、以为
+// 这张表没进去, 库里却已经躺了半张表。实现 Discarder 后, 出错路径会走 Discard
+// (SQL 侧 Rollback), 缓冲区里那批行原样丢弃。
+//
+// 未实现的写入器 (如 VRTX) 仍退回 Close: 它的 Close 不提交 (提交由 Committer 在
+// 整表成功后单独调用), 所以没有这个问题。
+type Discarder interface {
+	// Discard 丢弃当前事务与缓冲区里尚未提交的行。
+	Discard() error
+}
+
 // Options 迁移选项。
 //
 // 用法约定: 从 DefaultOptions() 出发改自己关心的字段。

+ 137 - 0
migrate/validate.go

@@ -0,0 +1,137 @@
+package migrate
+
+import (
+	"TsDB2/DB/core"
+	"fmt"
+	"strings"
+)
+
+// ============================ 迁移侧的结构校验 ============================
+//
+// 为什么单独有一层: 源库 (MySQL / SQLite) 的结构能力与 VRTX 不是子集关系。源侧完全合法、
+// 到 VRTX 侧却无处安放的东西不少 ——
+//
+//	源库 (常见上限)                        VRTX
+//	MySQL 列数 1017 (InnoDB 硬限)          core.MaxFieldsPerTable = 254
+//	MySQL/SQLite 表名 64 / 不限            core.TableNameSize = 48
+//	MySQL 列名 64, SQLite 几乎不限         core.FieldNameSize = 255
+//	MySQL 索引名 64, SQLite 几乎不限       core.IndexNameSize = 64
+//	MySQL 索引分量最多 16, SQLite 更多     core.MaxIndexFields = 16
+//	MySQL 主键可以是复合键                 VRTX 的行存储索引只能单列
+//	MySQL DECIMAL(65,30)                   p ≤ 38 且 s ≤ 31 (否则退 float64)
+//
+// 不在这里拦, 这些差异就会以 VRTX【内部约束】的样子冒出来:
+//
+//	建 VRTX 表 t: 字段数量非法: 一张表最多 254 列, 本次 900 列
+//
+// 用户看不出 "900" 是源库的事实、"254" 是引擎的边界, 更不知道该改哪一边。所以这里在读
+// 完源结构、建表之前跑一遍, 把源库的实际值与 VRTX 的上限摆在一起说。
+//
+// 两类处理:
+//   - 硬错误: 目标表【建不出来】, 只能拒绝 (改源结构, 或用 Options.Tables 跳过);
+//   - 报告:   建得出来, 但容量/语义与原库不同 —— 写进 sourceNotes, 由 runTable 记进
+//     迁移报告。与 SkippedIndexes 同一条原则: 静默改变语义比报错更糟, 但也不该让整张表
+//     的数据陪葬 (索引能补、容量是小概率、精度问题用户知情后可自行取舍)。
+//
+// 行宽 ("一页两行", types.MaxRowDataSize) 是这一类里唯一【能自动救】的: 建表前先把宽度
+// 超过 256 字节的定长字符串列降为动态列, 降完还超才拒 —— 见 fitRowWidthForVRTX
+// (widerow.go), 由 runTable 在本文件之后调用。
+//
+// 只在目标端是 VRTX 时跑: SQL 一侧的约束比这些宽松得多 (MySQL/SQLite 都收得下 VRTX 的
+// 任何结构), 往 SQL 迁移时这层是空的。
+
+// sourceNotes 读端在源库结构里发现的、VRTX 端承载不了的东西。
+//
+// 三类分开存, 因为下游处理不同:
+//   - Capped / Degraded: 只报, 不影响建表;
+//   - PK: 复合主键 —— 有 AutoPK 时补 _id 继续迁 (报), 没有时表根本建不出来 (硬错误),
+//     所以它单独一格, 两种下场都能说清楚。
+//
+// 合成一句话会让"装不下"和"类型变了"混在一起, 用户没法据此决定要不要继续。
+type sourceNotes struct {
+	// Capped 列装不下声明的容量 (类型不变, 上限变小), 见 capacityNote。
+	Capped []string
+	// Degraded 列只能降级收编, 类型都换了 (如 DECIMAL → float64), 见 degradeNote。
+	Degraded []string
+	// PK 源表主键表达了、但 VRTX 承载不了 (复合主键) 的说明; 空表示没这问题。
+	PK string
+}
+
+// validateForVRTX 核对"这张表能不能在 VRTX 里立起来"。
+//
+// 必须在 prepareForVRTX 之后调用: 那时列清单才是最终形态 (可能已经补了 _id 列、补了索引),
+// 校验的才是"真正要落下去的那张表"。反过来在之前校验, 补出来的列就不在检查范围内。
+func validateForVRTX(s *TableSchema) error {
+	// 表名: MySQL 给到 64 字节, VRTX 只到 core.TableNameSize(48)。core 也会拒, 但那里的
+	// 报错只说"表名过长", 看不出这是"源库比目标库宽"的问题, 也拿不到源名的长度。
+	if n := len(s.Name); n > core.TableNameSize {
+		return fmt.Errorf("表 %q 的名字有 %d 字节, 超过 VRTX 上限 %d 字节 "+
+			"(源库的表名通常允许到 64 字节): 请把源表名改短, 或用 Options.Tables 跳过它",
+			s.Name, n, core.TableNameSize)
+	}
+
+	// 列数: 迁移里最容易被源库【合法地】撞到的一条 —— MySQL (InnoDB) 允许 1017 列,
+	// 而 VRTX 的 FieldCount 与 schema 字段索引都只有 1 字节, 上限 core.MaxFieldsPerTable。
+	//
+	// 注意含已被 DropField 软删除的列: 那些列仍占 master registry 的位置, 删过的表能加的
+	// 新列更少。这里按最终列清单算, 与 core 的 checkFieldCount 同口径。
+	if n := len(s.Columns); n > core.MaxFieldsPerTable {
+		return fmt.Errorf("表 %s 有 %d 列, 超过 VRTX 上限 %d 列 "+
+			"(源库通常允许到 1017 列): 请拆分源表, 或只迁其中的一部分列",
+			s.Name, n, core.MaxFieldsPerTable)
+	}
+
+	// 列名: schema 里是 [nameLen(1B)][name...]。超过 255 字节会让 nameLen 截断, 之后所有
+	// 列从错位偏移解码 —— 不是"名字被截短"这么轻, 是整表错乱。SQLite 的列名几乎没有长度限制。
+	for _, c := range s.Columns {
+		if len(c.Name) == 0 {
+			return fmt.Errorf("表 %s 有一列的名字为空: VRTX 的 schema 里列名长度占 1 字节, "+
+				"空名字会让解析侧把下一个字段的类型字节当名字", s.Name)
+		}
+		if n := len(c.Name); n > core.FieldNameSize {
+			return fmt.Errorf("表 %s 的列名 %q 有 %d 字节, 超过 VRTX 上限 %d 字节 "+
+				"(schema 里列名长度占 1 字节, 超长会让后续列整体错位)",
+				s.Name, c.Name, n, core.FieldNameSize)
+		}
+	}
+
+	// 行存储索引的分量数: VRTX 的行数据寄存在行存储索引的条目里, 而它只能是单列
+	// (RowStore() 已经按"未显式指定就是 [PKColumn]"取好, 这里兜住显式指定多列的情况)。
+	if rs := s.RowStore(); len(rs) > 1 {
+		return fmt.Errorf("表 %s 的行存储索引指定了 %d 列 (%v), VRTX 只支持单列行存储索引",
+			s.Name, len(rs), rs)
+	}
+	return nil
+}
+
+// auditIndexForVRTX 检查一个二级索引能不能在 VRTX 里建出来, 不能则返回原因 (可读的短语)。
+//
+// 与 validateForVRTX 的区别是【处理方式】: 表和列建不出来就没有行可以放, 只能拒绝;
+// 而索引建不出来不影响数据 —— 与 ErrIndexDynamicField 一样跳过并记进 SkippedIndexes,
+// 让用户事后自行补 (索引丢了还能补, 数据没迁才是真丢)。
+//
+// 空返回表示可以建。
+func auditIndexForVRTX(idx IndexSchema) string {
+	// 索引名: VRTX 上限 core.IndexNameSize。SQLite 的索引名几乎没有长度限制, MySQL 给到
+	// 64 字节 (刚好等于 VRTX 的上限, 不多不少)。
+	if err := checkIndexName(idx.Name); err != nil {
+		return err.Error()
+	}
+	// 分量数: IndexMeta.FieldIDs 是定长 [16]byte, 多出来的分量无处落盘。
+	//
+	// SQLite 的索引能引用的列数几乎没有限制 (上限跟着表的列数走), 所以这条不是理论值。
+	if n := len(idx.Columns); n > core.MaxIndexFields {
+		return fmt.Sprintf("索引有 %d 个分量列, 超过 VRTX 上限 %d 个",
+			n, core.MaxIndexFields)
+	}
+	return ""
+}
+
+// compositePKNote 复合主键的说明。
+//
+// VRTX 的行数据寄存在行存储索引的条目里, 而那个索引只能单列 —— 复合主键承载不了。
+// 措辞里必须带上列清单: 用户据此判断"拆表 / 改成单列主键"还是"接受降级"。
+func compositePKNote(table string, cols []string) string {
+	return fmt.Sprintf("源表 %s 的主键是复合键 (%s): VRTX 的行存储索引只支持单列, "+
+		"该主键无法原样承载 (复合唯一性不再由主键保证)", table, strings.Join(cols, ", "))
+}

+ 335 - 0
migrate/validate_test.go

@@ -0,0 +1,335 @@
+package migrate
+
+import (
+	"TsDB2/DB/core"
+	"TsDB2/DB/core/types"
+	"database/sql"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"testing"
+)
+
+// ============================ 迁移侧的结构闸门 ============================
+//
+// 这一层存在的理由: 源库 (MySQL/SQLite) 的结构能力比 VRTX 宽, 源侧合法的东西到 VRTX 侧
+// 无处安放。测的重点不只是"报错了", 而是【报错里有没有用户能据以行动的信息】——
+// 源库的实际值 (900 列 / 65 字节表名) 和 VRTX 的上限 (254 / 48) 都要在里面。
+// 只说一句"字段数量非法", 用户无从判断该改源表还是该拆表。
+
+func atMostColumns(n int) []Column {
+	cols := make([]Column, n)
+	for i := range cols {
+		cols[i] = Column{Name: "c" + strconv.Itoa(i), Type: types.FieldType_1byte}
+	}
+	return cols
+}
+
+func TestValidateForVRTX(t *testing.T) {
+	// ---- 列数: MySQL 允许 1017 列, VRTX 只到 core.MaxFieldsPerTable ----
+	over := &TableSchema{Name: "wide", Columns: atMostColumns(core.MaxFieldsPerTable + 1)}
+	err := validateForVRTX(over)
+	if err == nil {
+		t.Fatal("列数超限必须报错")
+	}
+	// 源库的事实 + 引擎的边界, 两个数都要在报错里
+	for _, want := range []string{
+		strconv.Itoa(core.MaxFieldsPerTable + 1), // 源表有多少列
+		strconv.Itoa(core.MaxFieldsPerTable),     // VRTX 上限
+		"wide",
+	} {
+		if !strings.Contains(err.Error(), want) {
+			t.Errorf("列数超限的报错应包含 %q: %v", want, err)
+		}
+	}
+	// 正好到上限: 放行 (闸门是 >, 不是 >=)
+	exact := &TableSchema{Name: "wide", Columns: atMostColumns(core.MaxFieldsPerTable)}
+	if err := validateForVRTX(exact); err != nil {
+		t.Errorf("正好 %d 列应当放行: %v", core.MaxFieldsPerTable, err)
+	}
+
+	// ---- 表名: MySQL 给到 64 字节, VRTX 只到 core.TableNameSize(48) ----
+	longName := strings.Repeat("t", core.TableNameSize+1)
+	err = validateForVRTX(&TableSchema{Name: longName, Columns: atMostColumns(2)})
+	if err == nil {
+		t.Fatal("表名超长必须报错")
+	}
+	for _, want := range []string{
+		strconv.Itoa(len(longName)),      // 源表名多长
+		strconv.Itoa(core.TableNameSize), // VRTX 上限
+	} {
+		if !strings.Contains(err.Error(), want) {
+			t.Errorf("表名超长的报错应包含 %q: %v", want, err)
+		}
+	}
+
+	// ---- 列名: schema 里是 [nameLen(1B)][name...] ----
+	err = validateForVRTX(&TableSchema{Name: "t", Columns: []Column{{Name: ""}}})
+	if err == nil {
+		t.Fatal("空列名必须报错 (会让解析侧把下一个字段的类型字节当名字)")
+	}
+	longCol := strings.Repeat("x", core.FieldNameSize+1)
+	err = validateForVRTX(&TableSchema{Name: "t", Columns: []Column{{Name: longCol}}})
+	if err == nil {
+		t.Fatal("超长列名必须报错")
+	}
+	if !strings.Contains(err.Error(), strconv.Itoa(core.FieldNameSize)) {
+		t.Errorf("超长列名的报错应给出上限 %d: %v", core.FieldNameSize, err)
+	}
+
+	// ---- 行存储索引: VRTX 只支持单列 ----
+	err = validateForVRTX(&TableSchema{
+		Name:            "t",
+		Columns:         atMostColumns(3),
+		RowStoreColumns: []string{"a", "b"},
+	})
+	if err == nil {
+		t.Fatal("多列行存储索引必须报错")
+	}
+	if !strings.Contains(err.Error(), "a") || !strings.Contains(err.Error(), "b") {
+		t.Errorf("行存储索引报错应点出是哪几列: %v", err)
+	}
+
+	// ---- 正常表: 一条都不该报 ----
+	ok := &TableSchema{Name: "t", Columns: atMostColumns(3), PKColumn: "c0"}
+	if err := validateForVRTX(ok); err != nil {
+		t.Errorf("合法表不该被拦: %v", err)
+	}
+}
+
+func TestAuditIndexForVRTX(t *testing.T) {
+	// 索引名: SQLite 的索引名几乎没有长度限制, VRTX 只到 core.IndexNameSize
+	longName := strings.Repeat("i", core.IndexNameSize+1)
+	if got := auditIndexForVRTX(IndexSchema{Name: longName, Columns: []string{"a"}}); got == "" {
+		t.Error("超长索引名应当被判为建不了")
+	} else if !strings.Contains(got, strconv.Itoa(core.IndexNameSize)) {
+		t.Errorf("超长索引名应给出上限 %d: %s", core.IndexNameSize, got)
+	}
+
+	// 分量数: IndexMeta.FieldIDs 是定长 [16]byte
+	many := make([]string, core.MaxIndexFields+1)
+	for i := range many {
+		many[i] = "c" + strconv.Itoa(i)
+	}
+	got := auditIndexForVRTX(IndexSchema{Name: "idx_many", Columns: many})
+	if got == "" {
+		t.Fatal("分量过多的索引应当被判为建不了")
+	}
+	if !strings.Contains(got, strconv.Itoa(core.MaxIndexFields)) {
+		t.Errorf("分量过多应给出上限 %d: %s", core.MaxIndexFields, got)
+	}
+
+	// 正常索引: 空返回 = 可以建
+	if got := auditIndexForVRTX(IndexSchema{Name: "idx_ok", Columns: []string{"a", "b"}}); got != "" {
+		t.Errorf("正常索引不该被判为建不了: %s", got)
+	}
+}
+
+// TestDegradeNoteAndInferAgree 钉住"报的"与"做的"逐条一致。
+//
+// degradeNote 只负责【说】, 真正决定类型的是 inferSQLType —— 两者判定条件一旦分叉,
+// 报告就成了假消息: 要么列退成了 float64 却不吭声, 要么没退却吓唬用户。所以这里对每个
+// 声明同时问两边, 要求它们同进同退。
+func TestDegradeNoteAndInferAgree(t *testing.T) {
+	cases := []struct {
+		decl string
+		// wantDegrade = 期望"确实降级了"; wantWhich = 报错里该出现的关键词
+		wantDegrade bool
+		wantWhich   string
+	}{
+		{"DECIMAL(18,2)", false, ""},
+		{"DECIMAL(38,2)", false, ""},   // p 到 38 仍可精确表达
+		{"DECIMAL(38,31)", false, ""},  // s 到 31 仍可表达
+		{"DECIMAL(39,0)", true, "39"},  // p 超 38
+		{"DECIMAL(65,30)", true, "65"}, // 同上, 这是 MySQL 能建出来、VRTX 装不下的真实例子
+		{"DECIMAL(38,35)", true, "35"}, // s 超 31: 系数装得下, 小数位无处表达
+		{"NUMERIC(20,6)", false, ""},
+		// 与 decimal 无关的声明一律不吭声 (这个函数不该对别的类型有意见)
+		{"INT", false, ""},
+		{"VARCHAR(4000)", false, ""},
+		{"LONGTEXT", false, ""},
+		{"", false, ""},
+	}
+	for _, c := range cases {
+		note := degradeNote(c.decl)
+		ft, _ := inferSQLType(c.decl, true)
+		if c.wantDegrade {
+			if note == "" {
+				t.Errorf("%q: 退成了 float64 却没报出来 (静默丢精度)", c.decl)
+				continue
+			}
+			if !strings.Contains(note, c.wantWhich) {
+				t.Errorf("%q: 报错应指明是哪一条超限 (%s): %s", c.decl, c.wantWhich, note)
+			}
+			if ft != types.FieldType_Float64 {
+				t.Errorf("%q: 说降级了, inferSQLType 却给了 %v", c.decl, ft)
+			}
+			continue
+		}
+		if note != "" {
+			t.Errorf("%q: 没降级却报了降级: %s", c.decl, note)
+		}
+	}
+}
+
+// TestSQLTypeNameDecimalWidth VRTX 的 Decimal 声明出去必须装得下 VRTX 里最大的系数。
+//
+// 以前按 DECIMAL(MaxDecimalScale, s) = DECIMAL(31,s) 声明, 于是 scale=30 的列只剩 1 位
+// 整数位, 而 VRTX 的系数上限是 10^38-1 —— 导出 8 位整数的值时会被 MySQL 判成超范围。
+func TestSQLTypeNameDecimalWidth(t *testing.T) {
+	for _, scale := range []byte{0, 2, 20, 30, 31} {
+		decl := sqlTypeName(types.FieldType_Decimal, scale, true)
+		if decl != "DECIMAL("+strconv.Itoa(types.MaxDecimalPrecision)+","+strconv.Itoa(int(scale))+")" {
+			t.Errorf("Decimal(%d) 的 MySQL 声明 = %q, 应为 DECIMAL(%d,%d)",
+				scale, decl, types.MaxDecimalPrecision, scale)
+		}
+		// 声明出去再读回来, 类型与小数位都不能变 (否则往返一次就换了语义)
+		ft, param := inferSQLType(decl, true)
+		if ft != types.FieldType_Decimal || param != scale {
+			t.Errorf("Decimal(%d) 往返后变成 (%v,%d)", scale, ft, param)
+		}
+	}
+}
+
+// TestCompositePKNote 复合主键的报告必须点出"源表有主键, 只是多列"。
+//
+// 以前读端直接把主键丢成空串, 下游报的是"源表没有主键"—— 用户会去源库找一个不存在的
+// 问题 (源表的主键好好的), 真正的原因是 VRTX 的行存储索引只能单列。
+func TestCompositePKNote(t *testing.T) {
+	note := compositePKNote("orders", []string{"tenant_id", "order_no"})
+	for _, want := range []string{"orders", "tenant_id", "order_no", "复合键"} {
+		if !strings.Contains(note, want) {
+			t.Errorf("复合主键的报告应包含 %q: %s", want, note)
+		}
+	}
+}
+
+// ============================ 端到端: 闸门真的接在迁移路径上 ============================
+//
+// 上面测的是校验函数本身。一个"从来没被调用过"的校验函数同样能过那些用例, 所以这里从
+// Run 走一遍: 源库 (SQLite, 与 MySQL 在"结构比 VRTX 宽"这点上同类) 真有这样的表时,
+// 迁移是不是按预期拒绝 / 跳过。
+
+// TestWideTableRejectedBeforeBuild 源库列数超过 VRTX 上限 → 建表之前就报错, 不留半成品。
+//
+// MySQL (InnoDB) 允许 1017 列而 VRTX 只到 core.MaxFieldsPerTable。不前置检查, 这条路会
+// 一直走到 core 写 FieldCount 时才炸, 报出一句与源库无关的"字段数量非法"—— 用户不知道
+// 该改源表还是该拆表。
+func TestWideTableRejectedBeforeBuild(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "src.sqlite")
+	dstPath := filepath.Join(dir, "dst.db")
+
+	// 比上限多 32 列: 源库(SQLite)建得出来, VRTX 建不出来
+	const n = core.MaxFieldsPerTable + 32
+	cols := make([]string, 0, n)
+	for i := 0; i < n; i++ {
+		cols = append(cols, "c"+strconv.Itoa(i)+" INTEGER")
+	}
+	db, err := sql.Open("sqlite", srcPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, s := range []string{
+		`CREATE TABLE Wide (id INTEGER PRIMARY KEY, ` + strings.Join(cols, ", ") + `)`,
+		`INSERT INTO Wide (id, c0) VALUES (1, 7)`,
+	} {
+		if _, err := db.Exec(s); err != nil {
+			t.Fatalf("建源表: %v", err)
+		}
+	}
+	_ = db.Close()
+
+	s1, err := OpenSQLite(srcPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer s1.Close()
+	v1, err := OpenVRTX(dstPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer v1.Close()
+
+	_, err = Run(s1, v1, quiet())
+	if err == nil {
+		t.Fatal("列数超过 VRTX 上限的表必须被拒")
+	}
+	for _, want := range []string{
+		"Wide",
+		strconv.Itoa(n + 1),                  // 源表实际有多少列 (n 个 + 主键 id)
+		strconv.Itoa(core.MaxFieldsPerTable), // VRTX 上限
+	} {
+		if !strings.Contains(err.Error(), want) {
+			t.Errorf("列数超限的报错应包含 %q: %v", want, err)
+		}
+	}
+
+	// 闸门在【建表之前】: 目标库里不该留下一张半成品 Wide
+	names, err := v1.Tables()
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, nm := range names {
+		if nm == "Wide" {
+			t.Error("被拒的表不该在目标库里留下半成品")
+		}
+	}
+}
+
+// TestLongIndexNameSkippedNotFatal 索引名超长只该跳那一条索引, 不该让整张表陪葬。
+//
+// SQLite 的索引名几乎没有长度限制, VRTX 只到 core.IndexNameSize(64)。这类"源库合法、
+// 目标装不下"的索引以前会直接 return err 把整张表拦下来 —— 数据本来一条都不丢, 却因为
+// 一个名字迁不过去。跳过的同时必须写进报告 (静默丢索引等于悄悄改了结构)。
+func TestLongIndexNameSkippedNotFatal(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "src.sqlite")
+	dstPath := filepath.Join(dir, "dst.db")
+
+	longIdx := "idx_" + strings.Repeat("x", core.IndexNameSize) // 比上限长
+	db, err := sql.Open("sqlite", srcPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, s := range []string{
+		`CREATE TABLE T (id INTEGER PRIMARY KEY, name VARCHAR(32) NOT NULL)`,
+		`CREATE INDEX ` + longIdx + ` ON T(name)`,
+		`INSERT INTO T (id, name) VALUES (1, 'a')`,
+	} {
+		if _, err := db.Exec(s); err != nil {
+			t.Fatalf("执行 %s: %v", s, err)
+		}
+	}
+	_ = db.Close()
+
+	s1, err := OpenSQLite(srcPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer s1.Close()
+	v1, err := OpenVRTX(dstPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer v1.Close()
+
+	rep, err := Run(s1, v1, quiet())
+	if err != nil {
+		t.Fatalf("一条索引建不出来不该让整张表失败: %v", err)
+	}
+	if rep.Rows != 1 {
+		t.Errorf("数据应当照常迁过去, 实际 %d 行", rep.Rows)
+	}
+	if len(rep.Tables) != 1 {
+		t.Fatalf("应当只有一张表的报告, 实际 %d", len(rep.Tables))
+	}
+	note := rep.Tables[0].Note
+	if !strings.Contains(note, "跳过索引") {
+		t.Errorf("跳过的索引必须报出来: %q", note)
+	}
+	// 报错要能让用户判断该怎么办: 得说清是"名字超过 64 字节"
+	if !strings.Contains(note, strconv.Itoa(core.IndexNameSize)) {
+		t.Errorf("跳过的索引报告应给出上限 %d: %q", core.IndexNameSize, note)
+	}
+}

+ 26 - 6
migrate/value.go

@@ -234,7 +234,9 @@ func coerceForVRTX(ft types.FieldType, param byte, v any, col string, strict boo
 			return nil, fmt.Errorf("列 %s: %w", col, err)
 		}
 		if !types.IsDynamic(ft) {
-			size := int(types.GetSize(ft))
+			// 容量取列的真实宽度 (宽度码), 不是类型默认档: 对声明 1024 字节的列按 256
+			// 判断会把合法值判成超长。
+			size := int(types.FieldSize(ft, param))
 			if len(b) > size {
 				if strict {
 					return nil, fmt.Errorf("列 %s (%s): 值 %d 字节超过定长列容量 %d 字节, "+
@@ -327,13 +329,16 @@ func parseInt128(s string) (*big.Int, error) {
 
 // coerceForSQL 把来源值规范成 database/sql 能绑定的类型。
 //
-// 两个约定:
-//   - 8byte 一律绑成 int64 (位模式)。目标列声明成 BIGINT (有符号), 于是
-//     VRTX 的 uint64 0xFFFF... 在 MySQL 里显示 -1, 回来时又是同一个位模式。
+// 三个约定:
+//   - 8byte 必须按方言选 Go 类型 (mysql 参数), 因为两个方言的声明本就不同,
+//     绑定类型得跟着走 —— 见下面 FieldType_8byte 分支。
 //   - 字节类列 (定长串 / 变长) 若是合法 UTF-8 就绑成 string, 否则绑 []byte。
 //     前者让 SQLite 把它存成 TEXT (BLOB 亲和性的列不会自动转换),
 //     后者保证二进制列不被改坏。定长列导出时去掉尾部补零。
-func coerceForSQL(c Column, v any) (any, error) {
+//   - 时间列 (TimeKind 非空) 一律还原成时间文本, 不写纳秒整数。
+//
+// mysql 来自目标方言 (Sink), 与 sqlTypeName 的第二个参数同源。
+func coerceForSQL(c Column, v any, mysql bool) (any, error) {
 	if v == nil {
 		// 可空列: 原样写 NULL。以前所有列都不可空, 这里一路把 nil 落成零值 ——
 		// 在可空列上那是【静默改数据】: VRTX 的 NULL 到了 SQL 变成 0 / 空串 /
@@ -383,6 +388,17 @@ func coerceForSQL(c Column, v any) (any, error) {
 			// DATETIME 列会收到一个 1.7e18 的整数 (驱动不会替我们转)。
 			return formatSQLTime(int64(u), c.TimeKind), nil
 		}
+		// 绑定的 Go 类型必须跟目标列的声明一致, 而两个方言的声明不同:
+		//   - MySQL: sqlTypeName 声明成 BIGINT UNSIGNED (8byte 是无符号档),
+		//     所以绑 uint64 —— 整个 2^64 区间都写得进去。以前一律绑 int64(u),
+		//     高位为 1 的值 (包括 VRTX 从 int64 负值搬过来的位模式) 在严格模式下
+		//     会被 MySQL 判成 "Out of range value" 直接报错, 非严格模式则被静默改写。
+		//   - SQLite: 只有有符号的 INTEGER, 绑 int64 —— 位模式原样存进 64 位整数,
+		//     读回来还是同一个 8 字节。
+		// 两条路共同的保证是【位模式不变】, 这正是 VRTX 8byte 的语义 (不分正负)。
+		if mysql {
+			return u, nil
+		}
 		return int64(u), nil
 
 	case types.FieldType_Int8, types.FieldType_Int16, types.FieldType_Int32, types.FieldType_Int64:
@@ -558,7 +574,11 @@ func roundFloatToInt(f float64) (int64, error) {
 	if math.IsNaN(f) || math.IsInf(f, 0) {
 		return 0, fmt.Errorf("值 %v 不是有限数", f)
 	}
-	if f > math.MaxInt64 || f < math.MinInt64 {
+	// 上界必须是 >= 而不是 >: math.MaxInt64 (2^63-1) 在 float64 里存不下, 会被舍入
+	// 到 2^63 —— 于是 f == 2^63 时 "f > MaxInt64" 是假, 放过去之后 int64(f) 越界,
+	// Go 对越界的 float→int 转换是未定义结果 (amd64 上给 MinInt64), 静默存成反数。
+	// 下界不受这个影响: MinInt64 = -2^63 在 float64 里可以精确表示。
+	if f >= math.MaxInt64 || f < math.MinInt64 {
 		return 0, fmt.Errorf("值 %v 超出 int64", f)
 	}
 	return int64(math.Round(f)), nil

+ 96 - 0
migrate/value_test.go

@@ -0,0 +1,96 @@
+package migrate
+
+import (
+	"TsDB2/DB/core/types"
+	"math"
+	"testing"
+)
+
+// TestRoundFloatToIntBoundary 钉住上界的写法。
+//
+// float64(math.MaxInt64) 存不下 2^63-1, 会被舍入成 2^63 —— 于是 "f > MaxInt64"
+// 这个看着像校验的守卫, 恰好放过了唯一越界的那一个值, 而 int64(2^63) 是越界转换
+// (结果未定义, amd64 上回绕成 MinInt64): 一个超范围的值静默变成反数存进去。
+// 下界不受影响: MinInt64 = -2^63 在 float64 里可以精确表示。
+func TestRoundFloatToIntBoundary(t *testing.T) {
+	if got, err := roundFloatToInt(0); err != nil || got != 0 {
+		t.Errorf("0 应当原样通过, 实际 (%d, %v)", got, err)
+	}
+	if _, err := roundFloatToInt(float64(math.MaxInt32)); err != nil {
+		t.Errorf("MaxInt32 远在范围内, 不该报错: %v", err)
+	}
+	// 下界是可以精确表示的那个值, 不该被误伤
+	if got, err := roundFloatToInt(math.MinInt64); err != nil || got != math.MinInt64 {
+		t.Errorf("MinInt64 应当原样通过, 实际 (%d, %v)", got, err)
+	}
+
+	// 注意别写 math.MinInt64 - 1 当越界样例: 那个数学值在 float64 里根本没有对应的
+	// 格点, 会被舍入回 -2^63 这个合法值。上界那边正好相反 —— MaxInt64 舍入到 2^63,
+	// 恰好越过边界, 所以 >= 才是对的。
+	for _, f := range []float64{
+		math.MaxInt64, // 2^63: 越界, 但 float64(MaxInt64) 也正好等于它
+		math.MaxInt64 * 2,
+		math.Pow(2, 63) * 4,
+		math.Inf(1),
+		math.Inf(-1),
+		math.NaN(),
+	} {
+		if got, err := roundFloatToInt(f); err == nil {
+			t.Errorf("%v 超出 int64, 应当报错, 实际返回 %d", f, got)
+		}
+	}
+}
+
+// TestCoerceForSQL8ByteByDialect 8byte 绑定的 Go 类型必须跟目标列的【声明】一致,
+// 而两个方言的声明本就不同 (见 sqlTypeName):
+//
+//   - MySQL 声明 BIGINT UNSIGNED → 绑 uint64。以前一律绑 int64(u), 高位为 1 的值
+//     (包括 VRTX 从 int64 负值搬过来的位模式) 在严格模式下会被判成
+//     "Out of range value" 直接报错, 非严格模式则被静默改写。
+//   - SQLite 只有有符号的 INTEGER → 绑 int64, 位模式原样存。
+//
+// 共同的保证是【位模式不变】; 两种绑定在各自的列里都必须无损。
+func TestCoerceForSQL8ByteByDialect(t *testing.T) {
+	col := Column{Name: "u", Type: types.FieldType_8byte}
+
+	cases := []struct {
+		name       string
+		in         any
+		wantMySQL  any
+		wantSQLite any
+	}{
+		{"uint64 上界", uint64(math.MaxUint64), uint64(math.MaxUint64), int64(-1)},
+		{"int64 负数 (位模式)", int64(-1), uint64(math.MaxUint64), int64(-1)},
+		{"普通值", int64(42), uint64(42), int64(42)},
+		{"2^63 (无符号档才装得下)", uint64(math.MaxInt64) + 1, uint64(math.MaxInt64) + 1, int64(math.MinInt64)},
+	}
+	for _, c := range cases {
+		gotMySQL, err := coerceForSQL(col, c.in, true)
+		if err != nil {
+			t.Errorf("%s: MySQL 侧不该报错: %v", c.name, err)
+		} else if gotMySQL != c.wantMySQL {
+			t.Errorf("%s: MySQL 侧 = %v (%T), 期望 %v (%T)",
+				c.name, gotMySQL, gotMySQL, c.wantMySQL, c.wantMySQL)
+		}
+
+		gotSQLite, err := coerceForSQL(col, c.in, false)
+		if err != nil {
+			t.Errorf("%s: SQLite 侧不该报错: %v", c.name, err)
+		} else if gotSQLite != c.wantSQLite {
+			t.Errorf("%s: SQLite 侧 = %v (%T), 期望 %v (%T)",
+				c.name, gotSQLite, gotSQLite, c.wantSQLite, c.wantSQLite)
+		}
+	}
+
+	// 时间列走的还是"还原成时间文本"那条路, 与方言无关 —— 别被上面的方言分支带走。
+	timeCol := Column{Name: "t", Type: types.FieldType_8byte, TimeKind: "datetime"}
+	for _, mysql := range []bool{true, false} {
+		v, err := coerceForSQL(timeCol, int64(0), mysql)
+		if err != nil {
+			t.Fatalf("时间列 (mysql=%v) 不该报错: %v", mysql, err)
+		}
+		if _, ok := v.(string); !ok {
+			t.Errorf("时间列 (mysql=%v) 应当还原成时间文本, 实际 %T", mysql, v)
+		}
+	}
+}

+ 26 - 11
migrate/vrtx.go

@@ -277,15 +277,7 @@ func (v *VRTX) CreateTable(s *TableSchema, recreate bool) error {
 		}
 	}
 
-	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:      columnFlag(c), // decimal scale + NULL / 唯一声明
-		})
-	}
-	tbl, err := v.db.CreateTable(s.Name, fields)
+	tbl, err := v.db.CreateTable(s.Name, vrtxFields(s))
 	if err != nil {
 		return fmt.Errorf("建 VRTX 表 %s: %w", s.Name, err)
 	}
@@ -326,8 +318,14 @@ func (v *VRTX) CreateTable(s *TableSchema, recreate bool) error {
 		if idx.Name == pkName || idx.IsRowStore() {
 			continue
 		}
-		if err := checkIndexName(idx.Name); err != nil {
-			return err
+		// 索引名超长 / 分量过多: 与下面的"动态列当键"同一处理 —— 跳过并记进报告。
+		// 这两种都是"源库合法、VRTX 装不下" (SQLite 的索引名与列数几乎没有限制), 但都
+		// 只影响这条索引, 不该让整张表的数据陪葬。以前这里直接 return err, 于是一个
+		// 名字太长的索引能把整张表拦下来。
+		if bad := auditIndexForVRTX(idx); bad != "" {
+			s.SkippedIndexes = append(s.SkippedIndexes, idx.Name+": "+bad)
+			v.opts.logf("[vrtx] 表 %s 的索引 %s 建不了, 跳过 (%s)", s.Name, idx.Name, bad)
+			continue
 		}
 		ids := make([]byte, 0, len(idx.Columns))
 		bad := ""
@@ -445,6 +443,23 @@ func columnFlag(c Column) byte {
 	return f
 }
 
+// vrtxFields 把列清单翻成 core 建表要的 []types.FieldInfo。
+//
+// 抽出来是因为它有两个用途, 两处必须逐字节一致 —— 建表用它, 行宽闸门 (更宽的行装不进
+// 一页两行) 也用它算行宽 (见 fitRowWidthForVRTX)。各写一份的话, 一旦某天只改了建表那份
+// (比如新增一个列标志位), 行宽闸门算的就是另一张表: 闸门放过的表建不出来。
+func vrtxFields(s *TableSchema) []types.FieldInfo {
+	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:      columnFlag(c), // decimal scale + NULL / 唯一声明
+		})
+	}
+	return fields
+}
+
 func columnIndex(s *TableSchema, name string) int {
 	if i, ok := s.ColumnIdx(name); ok {
 		return i

+ 100 - 0
migrate/widerow.go

@@ -0,0 +1,100 @@
+package migrate
+
+import (
+	"TsDB2/DB/core/types"
+	"fmt"
+)
+
+// ============================ 行宽超限时的自动降级 ============================
+//
+// 【问题】VRTX 单行的【总占用】上限是 types.MaxRowDataSize (8171 = 一页至少装下两行,
+// 见 types.CheckRowSize)。它是 【schema 属性】而不是行数据属性: 一张建出来就超限的表,
+// 之后每一行都写不进去, 所以引擎在 CreateTable / AddField 就拒绝。引擎那句提示
+// ("请把宽度超过 2048 字节的列改为动态列, 或把大列拆到附表") 对"自己写代码建表"够用 ——
+// 改 schema 重跑即可。
+//
+// 但迁移场景不一样: schema 是从源库读来的。用户没法"把源表的一个列宽改小"就为了迁过去,
+// 于是 40 列的表里只因 8 个大 varchar 就整张迁不动, 数据跟着陪葬。
+//
+// 【处理】建表前先算一次行宽; 超限就把【行内宽度 > minInlineBytes(256) 字节的定长字符串 /
+// 二进制列】降为动态列 (FieldType_Dynamicbyte: 行内只留 6 字节堆引用, 内容整体落堆页)。
+// 降完仍超限才报错 —— 那时已无列可降, 说明这张表的结构本身与"一页两行"冲突, 只能拆表。
+//
+// 【为什么这条线是 256】不是拍出来的:
+//   - 256 及以下是"够小到不该进堆"的档位。16..256 这几档的列统共才几十到两百多字节,
+//     降成动态反而多付一整套堆开销 (行内 6B 引用 + 堆块 4B 长度前缀 + 堆页记账), 还把
+//     本来可索引、可定长比较的短列拖进堆;
+//   - 真正吃掉行宽的是 512 / 1024 / 2048 这几档: 几列 2048 就能顶到 8171。只降它们,
+//     既解决问题, 又只牺牲"本来就该进堆"的大列;
+//   - 它也与引擎自己的分界同向: 声明超过 types.MaxInlineColumnWidth(2048) 的列本来就落
+//     动态列 (见 bucketColumn), 这里只是把 512..2048 这几档【按需】也推进去。
+//
+// 【降级必须报出来】降级是【改类型】(存的东西换了一种放法), 所以每一列都记进
+// sc.DegradedColumns, 由 runTable 汇进迁移报告 —— 与 prepareForVRTX 里"主键降级"同一条
+// 原则: 静默改结构比报错更糟。
+//
+// 【副作用同样是可见的】动态列不能做索引键 (core.CreateIndex 直接拒, 见 ErrIndexDynamicField),
+// 所以被降级的列上原有的索引会在 VRTX.CreateTable 里被跳过并记进 SkippedIndexes。用户看到的是
+// "这一列降级了" + "那条索引没建成"两句, 而不是"数据没迁过来"。
+//
+// 【为什么不像"最小改动"那样只降够用的几列】降哪些列取决于列的遍历顺序, 同一个源库两次迁移
+// 可能降出不同的结构; 而"所有 >256 的列一起降"是源库结构的确定性函数 —— 可复现、可对照, 也给
+// 留了余量 (之后加列不至于又贴上限)。代价是可能多降几列, 由报告如实告知。
+
+// minInlineBytes 行内宽度超过它才值得降为动态列。取值理由见文件头。
+const minInlineBytes = 256
+
+// fitRowWidthForVRTX 行宽闸门: 装得下就什么都不做; 装不下就把宽列降为动态列; 降完还装不下才报错。
+//
+// 返回被降级的列数 (0 表示没动过), 供调用方记日志。表结构就地修改。
+func fitRowWidthForVRTX(s *TableSchema) (int, error) {
+	if types.CheckRowSize(vrtxFields(s)) == nil {
+		return 0, nil
+	}
+
+	demoted := demoteWideColumns(s)
+	if demoted == 0 {
+		// 一个可降的列都没有 (>256 的定长字符串列不存在), 原样把引擎的口径报出去 ——
+		// 它已经列出了各列宽度与"拆表"的建议。
+		return 0, fmt.Errorf("表 %s: %w", s.Name, types.CheckRowSize(vrtxFields(s)))
+	}
+
+	if err := types.CheckRowSize(vrtxFields(s)); err != nil {
+		// 已经降过还是装不下: 必须说清"降过了"。否则用户会照着引擎那句"请把宽度超过
+		// 2048 字节的列改为动态列"去找一列, 而那一列早已是动态列 —— 无处可改。
+		return demoted, fmt.Errorf("表 %s: %w (已把 %d 个宽度超过 %d 字节的定长字符串列降为动态列, "+
+			"行仍装不进一页两行 —— 只能拆表, 或只迁其中一部分列)",
+			s.Name, err, demoted, minInlineBytes)
+	}
+	return demoted, nil
+}
+
+// demoteWideColumns 把宽度 > minInlineBytes 的定长字符串 / 二进制列降为动态列, 返回降了几个。
+//
+// 按列顺序遍历 (确定性), 且只降"类型本身能变长"的列:
+//   - 已是动态列的跳过 —— 重复调用是幂等的, 不会把一次降级重复记账;
+//   - 非字符串家族 (整数 / 浮点 / 定点 / UUID / 时间...) 不碰 —— 变长档装不了它们,
+//     降了是改数据语义 (见 types.IsStringType 的说明);
+//   - 行内宽度 <= minInlineBytes 的跳过, 理由见文件头。
+//
+// 行存储键列 (主键 / 时序键) 天然不会命中: prepareForVRTX 已经保证它是 8 字节整数列。
+func demoteWideColumns(s *TableSchema) int {
+	n := 0
+	for i := range s.Columns {
+		c := &s.Columns[i]
+		if c.IsDynamic() || !types.IsStringType(c.Type) || c.Size() <= minInlineBytes {
+			continue
+		}
+		width := c.Size()
+		c.Type = types.FieldType_Dynamicbyte
+		// 动态列【不保留声明长度】: Flag 低 5 位必须为 0 (见 types.ValidateColumnParam 的
+		// default 分支)。留着原宽度码会让 FieldSize 按定长算, 行宽又变回去。
+		c.Param = 0
+		n++
+		s.DegradedColumns = append(s.DegradedColumns, fmt.Sprintf(
+			"%s: 行内宽度 %d 字节, 为使整行装进一页两行已降为动态列 "+
+				"(行内只留 6 字节堆引用, 内容整体落堆页; 该列不再是索引键)",
+			c.Name, width))
+	}
+	return n
+}

+ 223 - 0
migrate/widerow_test.go

@@ -0,0 +1,223 @@
+package migrate
+
+import (
+	"TsDB2/DB/core/page"
+	"TsDB2/DB/core/types"
+	"bytes"
+	"fmt"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// wideColumn 造一个"源库声明了 n 字节容量"的字符串列。
+//
+// 走 bucketColumn (读端把 SQL 长度收编成列的那条路径) 而不是手写类型 + 宽度码:
+// 测试要验的正是"读端读出来的列, 到行宽闸门这里怎么处理", 手写一份等于把被测的映射
+// 又抄一遍 —— 映射改了测试不会跟着变。
+func wideColumn(name string, n int) Column {
+	ft, code := bucketColumn(n)
+	return Column{Name: name, Type: ft, Param: code}
+}
+
+// wideSchema 造一张行宽超限的表, 与线上撞过闸门的那张同构: 几列 2048 / 1024 顶到上限。
+//
+// 末两列是边界样本: resAlias(224) 与 resName(256) 都在 256 这条线以内, 必须留在定长档 ——
+// 一起降掉就等于把"可索引、可定长比较"的短列白白拖进堆。
+func wideSchema() *TableSchema {
+	cols := []Column{{Name: "_id", Type: types.FieldType_8byte}}
+	for _, c := range []struct {
+		name string
+		size int
+	}{
+		{"resOnlinePatch", 2048},
+		{"resChatCrowd", 2048},
+		{"resExtendParam1", 1024},
+		{"resExtendParam2", 1024},
+		{"resExtendParam3", 1024},
+		{"resExtendParam4", 1024},
+		{"resExtendParam5", 1024},
+		{"resExtendParam6", 1024},
+		{"resExtendParam7", 1024},
+		{"resAppID", 320}, // 320 > 256: 降
+		{"resAlias", 224}, // <= 256: 不降
+		{"resName", 256},  // 边界: 也不降
+	} {
+		cols = append(cols, wideColumn(c.name, c.size))
+	}
+	return &TableSchema{Name: "mesh_resource_info", Columns: cols, PKColumn: "_id"}
+}
+
+func TestFitRowWidthDemotesWideColumns(t *testing.T) {
+	sc := wideSchema()
+	if err := types.CheckRowSize(vrtxFields(sc)); err == nil {
+		t.Fatal("样本表本来就该超限, 否则这个用例什么都没验")
+	}
+
+	n, err := fitRowWidthForVRTX(sc)
+	if err != nil {
+		t.Fatalf("可降级的表不该报错: %v", err)
+	}
+	if want := 10; n != want {
+		t.Errorf("降级列数 = %d, 期望 %d (宽度 > 256 的那些)", n, want)
+	}
+	if err := types.CheckRowSize(vrtxFields(sc)); err != nil {
+		t.Fatalf("降级后行宽应当装得下: %v", err)
+	}
+
+	byName := make(map[string]Column, len(sc.Columns))
+	for _, c := range sc.Columns {
+		byName[c.Name] = c
+	}
+	// 宽列: 变成动态列, 且宽度码必须清零 —— 留着原宽度码 FieldSize 仍按定长算, 行宽又变回去。
+	for _, name := range []string{"resOnlinePatch", "resChatCrowd", "resExtendParam1", "resAppID"} {
+		c, ok := byName[name]
+		if !ok {
+			t.Fatalf("列 %s 丢了", name)
+		}
+		if !c.IsDynamic() {
+			t.Errorf("列 %s 应当降为动态列, 实际 %v", name, c.Type)
+		}
+		if c.Param != 0 {
+			t.Errorf("列 %s 的宽度码应当清零, 实际 %d", name, c.Param)
+		}
+		if c.Size() != types.DynamicRefSize {
+			t.Errorf("列 %s 的行内宽度 = %d, 期望堆引用 %d", name, c.Size(), types.DynamicRefSize)
+		}
+	}
+	// 窄列与行主键: 一个都不许动。
+	for name, want := range map[string]int{"resAlias": 224, "resName": 256, "_id": 8} {
+		c := byName[name]
+		if c.IsDynamic() {
+			t.Errorf("列 %s (行内 %d 字节) 不该降为动态列", name, c.Size())
+		}
+		if c.Size() != want {
+			t.Errorf("列 %s 的行内宽度 = %d, 期望 %d", name, c.Size(), want)
+		}
+	}
+	// 降级是改类型, 必须报出来 (runTable 靠它汇进迁移报告)。
+	if len(sc.DegradedColumns) != n {
+		t.Fatalf("DegradedColumns = %d 条, 期望 %d 条", len(sc.DegradedColumns), n)
+	}
+	for _, note := range sc.DegradedColumns {
+		if !strings.Contains(note, "动态列") || !strings.Contains(note, "索引键") {
+			t.Errorf("降级说明没讲清后果: %q", note)
+		}
+	}
+	if !strings.Contains(strings.Join(sc.DegradedColumns, "|"), "resChatCrowd") {
+		t.Errorf("降级说明里应当点名具体列: %v", sc.DegradedColumns)
+	}
+}
+
+// 没有可降的列 (全是 <= 256 的定长列) 时, 只能按引擎的口径拒绝 —— 降无可降, 硬装就是骗人。
+func TestFitRowWidthRejectsWhenNothingToDemote(t *testing.T) {
+	sc := &TableSchema{Name: "flat_wide", Columns: []Column{{Name: "_id", Type: types.FieldType_8byte}}}
+	for i := 0; i < 40; i++ {
+		sc.Columns = append(sc.Columns, wideColumn(fmt.Sprintf("c%02d", i), 256))
+	}
+	sc.PKColumn = "_id"
+
+	n, err := fitRowWidthForVRTX(sc)
+	if err == nil {
+		t.Fatal("降无可降时必须报错, 否则会建出一张每行都写不进去的表")
+	}
+	if n != 0 {
+		t.Errorf("降级列数 = %d, 期望 0 (这些列都在 256 以内)", n)
+	}
+	if !strings.Contains(err.Error(), "行宽") {
+		t.Errorf("报错应当保留引擎的口径 (行宽/上限/各列宽度): %v", err)
+	}
+	if len(sc.DegradedColumns) != 0 {
+		t.Errorf("没降就不该有降级说明: %v", sc.DegradedColumns)
+	}
+}
+
+// 幂等: 同一个 schema 跑第二遍不能再降一次 (否则 DegradedColumns 会重复记账)。
+func TestFitRowWidthIsIdempotent(t *testing.T) {
+	sc := wideSchema()
+	if _, err := fitRowWidthForVRTX(sc); err != nil {
+		t.Fatalf("第一次降级: %v", err)
+	}
+	notes := len(sc.DegradedColumns)
+
+	n, err := fitRowWidthForVRTX(sc)
+	if err != nil {
+		t.Fatalf("第二次不该报错: %v", err)
+	}
+	if n != 0 {
+		t.Errorf("第二次降级列数 = %d, 期望 0 (已经装得下了)", n)
+	}
+	if len(sc.DegradedColumns) != notes {
+		t.Errorf("降级说明重复记账: %d != %d", len(sc.DegradedColumns), notes)
+	}
+}
+
+// 端到端: 降级后的 schema 必须真的能在 VRTX 里立起来并接受大值 ——
+// 降级前这张表连 CreateTable 都过不去, 这正是要解决的问题。
+func TestDemotedSchemaBuildsAndAcceptsWideValues(t *testing.T) {
+	sc := wideSchema()
+	if _, err := fitRowWidthForVRTX(sc); err != nil {
+		t.Fatalf("行宽闸门: %v", err)
+	}
+	// 被降级的列上原有的索引: 动态列不能做索引键, 该索引应当被跳过并报进 SkippedIndexes,
+	// 而不是让整张表失败 —— 索引丢了能补, 数据没迁才是真丢。
+	sc.Indexes = append(sc.Indexes, IndexSchema{
+		Name: "idx_resChatCrowd", Type: page.IndexTypeBTree, Columns: []string{"resChatCrowd"},
+	})
+
+	v, err := OpenVRTX(filepath.Join(t.TempDir(), "wide.db"), quiet())
+	if err != nil {
+		t.Fatalf("开库: %v", err)
+	}
+	defer v.Close()
+	if err := v.CreateTable(sc, true); err != nil {
+		t.Fatalf("降级后建表: %v", err)
+	}
+	if len(sc.SkippedIndexes) == 0 {
+		t.Error("降级列上的索引应当被跳过并记进 SkippedIndexes")
+	}
+
+	tbl, ok := v.DB().FindTable("mesh_resource_info")
+	if !ok {
+		t.Fatal("建完表却找不到")
+	}
+	// 2000 字节 (超过 2048 档装不下、但动态列装得下) 的值必须原样进得去。
+	big := bytes.Repeat([]byte{'x'}, 2000)
+	vals := make([]types.FieldValue, 0, len(sc.Columns))
+	for _, c := range sc.Columns {
+		var val any
+		switch c.Name {
+		case "_id":
+			val = uint64(1)
+		case "resChatCrowd":
+			val = big
+		case "resAlias", "resName":
+			val = []byte(c.Name)
+		default:
+			val = []byte(nil)
+		}
+		vals = append(vals, types.FieldValue{FieldName: []byte(c.Name), Value: val})
+	}
+	if _, err := tbl.InsertRowWithID(1, vals); err != nil {
+		t.Fatalf("往降级后的列写 2000 字节: %v", err)
+	}
+
+	got, err := tbl.FindByPK(1)
+	if err != nil {
+		t.Fatalf("回读: %v", err)
+	}
+	for _, fv := range got {
+		if string(fv.FieldName) != "resChatCrowd" {
+			continue
+		}
+		raw, ok := fv.Value.([]byte)
+		if !ok {
+			t.Fatalf("动态列回读值类型 = %T, 期望 []byte", fv.Value)
+		}
+		if !bytes.Equal(raw, big) {
+			t.Errorf("动态列回读 %d 字节, 与写入的 %d 字节不一致", len(raw), len(big))
+		}
+		return
+	}
+	t.Error("回读结果里没有 resChatCrowd")
+}