Эх сурвалжийг харах

feat: 超大文本三档归宿 — 超16360落Blob链式列 + 驱动SELECT物化Blob + count列名解放

【migrate】TEXT 采样三态定宽 (textwidth.go): ≤2048 定长2次幂档 (可索引) /
≤16360 动态列 / >16360 Blob链式列 (行内8B引用+链页, 上限~4GB) — 修复
sys_logs.param 59566字节迁移必炸 (动态列单块上限 16360, 多页分块引擎
TODO)。报告 note 记录; fitRowWidth 不误降 Blob 列。60000字节往返测试
(含 VRTX→SQLite 回程逐字节对照)。

【DB/driver】SELECT 物化 Blob: rows 增加 blobReader (Table.BlobRead,
CompressNone 约定与 ORM 写侧一致), 流式 start/物化/JOIN 三路覆盖 —
BLOB 列从'报错不可用'变'读回[]byte 无感'; 聚合路径保持防御报错。
【parser】COUNT/SUM/MIN/MAX/AVG 移出保留字 — 列名可以叫 count (GORM
模型常见), 仅后跟 '(' 才按聚合解析; 聚合名判定补 ToUpper (GORM 发小写
count(*)); ORDER BY count / WHERE count>6 / UPDATE SET count 全通。
【gormvrtx】DataTypeOf/fieldInfoOf: 无 size string (含 type:text) 从
TEXT 改 BLOB — 新表大文本运行时不撞 16360; 带索引标签时在建表阶段
给 size:1..2048 提示; DataTypeOf 显式 TEXT→BLOB 同归宿。size>0 维持
VARCHAR(size)。

验证: 根8包+gormvrtx 全绿, 29 demo 全过。文档同步。
yl32940 3 долоо хоног өмнө
parent
commit
eac79c4ddb

+ 200 - 0
DB/driver/blob_test.go

@@ -0,0 +1,200 @@
+package vrtxsql
+
+// BLOB 列结果集物化测试: 行内只有 8B BlobRef, SELECT (流式) 与 JOIN (物化) 两条
+// 路径都必须读出内容字节 —— 60KB 值超动态列单值上限 16360, 只有链式存储装得下,
+// 物化错了 (或没接) 这两类查询当场就崩。
+
+import (
+	"database/sql"
+	"strings"
+	"testing"
+)
+
+// blobContent 60KB 测试内容: 超引擎动态列单值上限 (types.MaxStringSize = 16360),
+// 落 BLOB 列经链页存储, 用来证明物化读回的是整条链而不是行内 8 字节引用。
+const blobTestContent = "blob-payload-0123456789abcdef"
+
+func blobFixture(t *testing.T, db *sql.DB) string {
+	t.Helper()
+	exec := func(q string, args ...interface{}) {
+		t.Helper()
+		if _, err := db.Exec(q, args...); err != nil {
+			t.Fatalf("%s: %v", q, err)
+		}
+	}
+	exec(`CREATE TABLE docs (id BIGINT PRIMARY KEY, title VARCHAR(32) NOT NULL, content BLOB)`)
+	big := strings.Repeat(blobTestContent, 60000/len(blobTestContent)+1) // >16360 字节
+	exec(`INSERT INTO docs (id, title, content) VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?)`,
+		1, "big", big,
+		2, "small", "hi",
+		3, "nonum", nil) // NULL blob
+	return big
+}
+
+// TestBlobSelectMaterialize 单表 SELECT (流式路径): BLOB 列物化成内容字节,
+// 投影列 / * 展开 / WHERE / LIMIT 都要一致; NULL blob 保持 NULL。
+func TestBlobSelectMaterialize(t *testing.T) {
+	db := openTestDB(t)
+	big := blobFixture(t, db)
+
+	// 投影列 + WHERE + LIMIT
+	rows, err := db.Query(`SELECT title, content FROM docs WHERE id = ? LIMIT 1`, 1)
+	if err != nil {
+		t.Fatalf("query: %v", err)
+	}
+	defer rows.Close()
+	if !rows.Next() {
+		t.Fatal("应有一行")
+	}
+	var title string
+	var content []byte
+	if err := rows.Scan(&title, &content); err != nil {
+		t.Fatalf("scan: %v", err)
+	}
+	if title != "big" {
+		t.Fatalf("title=%q, 想要 big", title)
+	}
+	if string(content) != big {
+		t.Fatalf("content %d 字节与写入的 %d 字节不一致", len(content), len(big))
+	}
+	if rows.Next() {
+		t.Fatal("只应有一行")
+	}
+	if err := rows.Err(); err != nil {
+		t.Fatal(err)
+	}
+	rows.Close()
+
+	// * 展开 + 全表扫描 (worker 逐行物化)
+	all, err := db.Query(`SELECT * FROM docs ORDER BY id ASC`)
+	if err != nil {
+		t.Fatalf("select *: %v", err)
+	}
+	defer all.Close()
+	var (
+		seen  int
+		nilOK bool
+	)
+	for all.Next() {
+		var (
+			id      int64
+			title   string
+			content []byte
+		)
+		if err := all.Scan(&id, &title, &content); err != nil {
+			t.Fatalf("scan: %v", err)
+		}
+		switch id {
+		case 1:
+			if string(content) != big {
+				t.Fatalf("id=1 content 不一致 (%d 字节)", len(content))
+			}
+		case 2:
+			if string(content) != "hi" {
+				t.Fatalf("id=2 content=%q", content)
+			}
+		case 3:
+			if content != nil {
+				t.Fatalf("id=3 (NULL blob) content 应为 nil, 实际 %v", content)
+			}
+			nilOK = true
+		}
+		seen++
+	}
+	if err := all.Err(); err != nil {
+		t.Fatal(err)
+	}
+	if seen != 3 || !nilOK {
+		t.Fatalf("应扫到 3 行且含 NULL 行, 实际 %d 行 nilOK=%v", seen, nilOK)
+	}
+
+	// 短值按主键点查 (行内引用 → 链页, 1 页链)
+	var short []byte
+	if err := db.QueryRow(`SELECT content FROM docs WHERE id = ?`, 2).Scan(&short); err != nil {
+		t.Fatalf("点查: %v", err)
+	}
+	if string(short) != "hi" {
+		t.Fatalf("短值 content=%q", short)
+	}
+}
+
+// TestBlobJoinMaterialize JOIN 查询 (物化路径): 右表 BLOB 列在 scanJoinTable 里
+// 当场解引用; LEFT JOIN 未匹配行的 blob 列保持 NULL。
+func TestBlobJoinMaterialize(t *testing.T) {
+	db := openTestDB(t)
+	big := strings.Repeat(blobTestContent, 60000/len(blobTestContent)+1) // >16360 字节
+	exec := func(q string, args ...interface{}) {
+		t.Helper()
+		if _, err := db.Exec(q, args...); err != nil {
+			t.Fatalf("%s: %v", q, err)
+		}
+	}
+	exec(`CREATE TABLE authors (id BIGINT PRIMARY KEY, name VARCHAR(32) NOT NULL)`)
+	exec(`CREATE TABLE papers (id BIGINT PRIMARY KEY, author_id BIGINT, content BLOB)`)
+	exec(`INSERT INTO authors (id, name) VALUES (?, ?), (?, ?)`,
+		7, "ada", 8, "brian")
+	exec(`INSERT INTO papers (id, author_id, content) VALUES (?, ?, ?), (?, ?, ?)`,
+		1, 7, big,
+		2, 7, "hi")
+
+	// INNER JOIN + 右表条件下推: 命中行的 blob 内容物化出来
+	rows, err := db.Query(`SELECT a.name, p.content FROM authors a JOIN papers p ON p.author_id = a.id WHERE a.id = ? ORDER BY p.id ASC`, 7)
+	if err != nil {
+		t.Fatalf("join: %v", err)
+	}
+	defer rows.Close()
+	var got [2][]byte
+	var names [2]string
+	n := 0
+	for rows.Next() {
+		if n >= 2 {
+			t.Fatal("至多两行")
+		}
+		if err := rows.Scan(&names[n], &got[n]); err != nil {
+			t.Fatalf("scan: %v", err)
+		}
+		n++
+	}
+	if err := rows.Err(); err != nil {
+		t.Fatal(err)
+	}
+	rows.Close()
+	if n != 2 || names[0] != "ada" || names[1] != "ada" {
+		t.Fatalf("INNER JOIN 应出 ada 两行, 实际 %d 行 %v", n, names)
+	}
+	if string(got[0]) != big {
+		t.Fatalf("60KB content 物化不一致 (%d 字节)", len(got[0]))
+	}
+	if string(got[1]) != "hi" {
+		t.Fatalf("短 content=%q", got[1])
+	}
+
+	// LEFT JOIN: brian 没有论文, 右表 blob 列应为 NULL (不是空串/报错)
+	lr, err := db.Query(`SELECT a.name, p.content FROM authors a LEFT JOIN papers p ON p.author_id = a.id ORDER BY a.id ASC, p.id ASC`)
+	if err != nil {
+		t.Fatalf("left join: %v", err)
+	}
+	defer lr.Close()
+	type hit struct {
+		name    string
+		content []byte
+	}
+	var left []hit
+	for lr.Next() {
+		var h hit
+		if err := lr.Scan(&h.name, &h.content); err != nil {
+			t.Fatalf("scan: %v", err)
+		}
+		left = append(left, h)
+	}
+	if err := lr.Err(); err != nil {
+		t.Fatal(err)
+	}
+	// ada 两篇 + brian 补一个 NULL 右行 = 3 行
+	if len(left) != 3 {
+		t.Fatalf("LEFT JOIN 应出 3 行, 实际 %d (%+v)", len(left), left)
+	}
+	if left[2].name != "brian" || left[2].content != nil {
+		t.Fatalf("brian 行 content 应为 NULL, 实际 %v", left[2].content)
+	}
+}

+ 27 - 2
DB/driver/convert.go

@@ -13,6 +13,7 @@ import (
 	"strings"
 	"time"
 
+	"git.ycl.pub/yl32940/TsDB2/DB/core"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
 )
 
@@ -229,6 +230,27 @@ func parseLiteralAsType(fi *types.FieldInfo, s string) (interface{}, error) {
 // 引擎值 → driver.Value
 // =============================================================================
 
+// blobReaderOf 绑定某张表的 BlobRef 物化入口: 行内 8B 链引用 → 内容字节。
+//
+// compress 固定 CompressNone, 与写入侧严格一致 —— 引擎写路径 (Table.blobFieldRef)
+// 把驱动/migrate 写入的 []byte / string 内容按 CompressNone 落链 (FieldInfo 不持久化
+// 压缩标志, 压缩只是 Blob 载体 types.Blob 的运行时参数; 库内两处读回 —
+// DB.readBlobBytes 与 migrate 的 VRTX.Scan —— 都是这个取值)。空引用 (空内容, 非
+// NULL —— NULL 走行内位图、根本不会以 BlobRef 形态出现) 归一成空切片, Scan 到
+// []byte 得到 len==0 而不是 nil。
+func blobReaderOf(tbl *core.Table) func(types.BlobRef) ([]byte, error) {
+	return func(ref types.BlobRef) ([]byte, error) {
+		data, err := tbl.BlobRead(ref, types.CompressNone)
+		if err != nil {
+			return nil, err
+		}
+		if data == nil {
+			return []byte{}, nil
+		}
+		return data, nil
+	}
+}
+
 func fieldValueToDriver(v interface{}) (driver.Value, error) {
 	switch t := v.(type) {
 	case nil:
@@ -261,8 +283,11 @@ func fieldValueToDriver(v interface{}) (driver.Value, error) {
 	case time.Time:
 		return t, nil
 	case types.BlobRef:
-		// Blob 列行内只有引用; v1 不物化 blob 内容, 明确报错引导用引擎 API
-		return nil, fmt.Errorf("BLOB 列的结果集物化暂不支持 (引用 %v), 请用引擎 Blob API 读取", t)
+		// Blob 列行内只有 8B 链引用; 结果集物化由持有表句柄的路径经 blobReaderOf
+		// 解引用 (流式 rows.start / JOIN scanJoinTable, 见 driver.go / join.go) ——
+		// 这一层没有读取入口。仍带着引用走到这里 (聚合 MIN/MAX 命中 blob 列等)
+		// 只能明确报错, 不能静默吐出引用结构或 fmt.Sprint 的杂串。
+		return nil, fmt.Errorf("BLOB 列的值 (引用 %v) 没有读取入口, 聚合函数不支持 BLOB 列", t)
 	default:
 		// Decimal / UUID 等其余类型: 文本化出 (Scan 到 string 可用)
 		return []byte(fmt.Sprint(v)), nil

+ 28 - 10
DB/driver/driver.go

@@ -419,7 +419,12 @@ const streamBuf = 64
 
 // rows 同时支持两种模式:
 //   - 流式 (ch != nil): 普通 SELECT, worker 逐行回调送入; Close 前持锁。
-//   - 物化 (ch == nil): 单行聚合, 执行已完成、锁已释放。
+//   - 物化 (ch == nil): 单行聚合 / JOIN / SHOW, 执行已完成、锁已释放。
+//
+// blobReader 物化 Blob 列的读取入口 (blobReaderOf 绑定具体表): Blob 列行内只有
+// 8B 链引用, 结果集要的是内容字节 —— 流式路径在 worker 里 (持锁期间) 解引用。
+// 物化模式的两条路径不经过它: JOIN 在 scanJoinTable 里当场解引用 (join.go),
+// 聚合/SHOW/字面量行根本不产出 BlobRef。
 type rows struct {
 	cols  []string
 	metas []colMeta
@@ -435,7 +440,8 @@ type rows struct {
 	data [][]driver.Value
 	pos  int
 
-	release func()
+	blobReader func(types.BlobRef) ([]byte, error)
+	release    func()
 }
 
 var (
@@ -446,14 +452,15 @@ var (
 	_ driver.RowsColumnTypeLength           = (*rows)(nil)
 )
 
-func newStreamRows(cols []string, metas []colMeta, release func()) *rows {
+func newStreamRows(cols []string, metas []colMeta, blobReader func(types.BlobRef) ([]byte, error), release func()) *rows {
 	return &rows{
-		cols:    cols,
-		metas:   metas,
-		ch:      make(chan []driver.Value, streamBuf),
-		done:    make(chan struct{}),
-		ended:   make(chan struct{}),
-		release: release,
+		cols:       cols,
+		metas:      metas,
+		ch:         make(chan []driver.Value, streamBuf),
+		done:       make(chan struct{}),
+		ended:      make(chan struct{}),
+		blobReader: blobReader,
+		release:    release,
 	}
 }
 
@@ -487,7 +494,18 @@ func (r *rows) start(ctx context.Context, colNames []string, distinct bool, q *c
 					vals[i] = nil
 					continue
 				}
-				dv, err := fieldValueToDriverTyped(r.metas[i].ft, full[idx].Value)
+				raw := full[idx].Value
+				if ref, isRef := raw.(types.BlobRef); isRef && r.blobReader != nil {
+					// Blob 列行内只有 8B 链引用: 在持锁的 worker 里解引用成内容字节。
+					data, err := r.blobReader(ref)
+					if err != nil {
+						r.err = fmt.Errorf("读取 BLOB 列 %q: %w", cn, err)
+						return false
+					}
+					vals[i] = data
+					continue
+				}
+				dv, err := fieldValueToDriverTyped(r.metas[i].ft, raw)
 				if err != nil {
 					r.err = err
 					return false

+ 1 - 1
DB/driver/exec.go

@@ -539,7 +539,7 @@ func queryStatement(ctx context.Context, db *core.DB, sel *selectStmt, args []dr
 		q = q.Offset(int(off))
 	}
 
-	r := newStreamRows(colNames, metas, release)
+	r := newStreamRows(colNames, metas, blobReaderOf(tbl), release)
 	r.start(ctx, colNames, sel.distinct, q)
 	return r, nil
 }

+ 12 - 1
DB/driver/join.go

@@ -259,11 +259,22 @@ func scanJoinTable(jt *joinTable, cond core.Condition) ([]map[string]driver.Valu
 	}
 	var out []map[string]driver.Value
 	var convErr error
+	readBlob := blobReaderOf(jt.tbl) // Blob 列行内 8B 引用 → 内容字节 (持锁期间调)
 	err := q.Scan(func(full []types.FieldValue) bool {
 		m := make(map[string]driver.Value, len(full))
 		for _, fv := range full {
 			key := strings.ToLower(string(fv.FieldName))
-			dv, err := fieldValueToDriverTyped(jt.ft[key], fv.Value)
+			raw := fv.Value
+			if ref, isRef := raw.(types.BlobRef); isRef {
+				data, err := readBlob(ref)
+				if err != nil {
+					convErr = fmt.Errorf("读取 BLOB 列 %q: %w", fv.FieldName, err)
+					return false
+				}
+				m[key] = data
+				continue
+			}
+			dv, err := fieldValueToDriverTyped(jt.ft[key], raw)
 			if err != nil {
 				convErr = err
 				return false

+ 50 - 0
DB/driver/keyword_column_test.go

@@ -0,0 +1,50 @@
+package vrtxsql
+
+import "testing"
+
+// 列名与聚合函数同名 (count) — 关键字冲突回归。
+func TestCountColumnName(t *testing.T) {
+	db := openTestDB(t)
+	if _, err := db.Exec("CREATE TABLE t (id BIGINT PRIMARY KEY, `count` BIGINT, last_seen BIGINT)"); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := db.Exec("INSERT INTO t (id, count, last_seen) VALUES (1, 5, 100), (2, 9, 200), (3, 7, 300)"); err != nil {
+		t.Fatal(err)
+	}
+	// ORDER BY count (GORM 实触发形态: SELECT * ... ORDER BY count DESC, last_seen DESC)
+	rows2, err := db.Query("SELECT id FROM t ORDER BY count DESC, last_seen DESC LIMIT 100")
+	if err != nil {
+		t.Fatalf("ORDER BY count: %v", err)
+	}
+	defer rows2.Close()
+	var ids []int64
+	for rows2.Next() {
+		var id int64
+		if err := rows2.Scan(&id); err != nil {
+			t.Fatal(err)
+		}
+		ids = append(ids, id)
+	}
+	if len(ids) != 3 || ids[0] != 2 || ids[1] != 3 || ids[2] != 1 {
+		t.Fatalf("排序结果 %v", ids)
+	}
+	// WHERE 里用 count
+	var n int64
+	if err := db.QueryRow("SELECT COUNT(*) FROM t WHERE count > 6").Scan(&n); err != nil {
+		t.Fatalf("WHERE count: %v", err)
+	}
+	if n != 2 {
+		t.Fatalf("count>6 应 2 行, 实际 %d", n)
+	}
+	// 聚合 COUNT(*) 不受影响
+	if err := db.QueryRow("SELECT COUNT(*) FROM t").Scan(&n); err != nil || n != 3 {
+		t.Fatalf("COUNT(*) 异常: %v %d", err, n)
+	}
+	// UPDATE/DELETE 用 count 列
+	if _, err := db.Exec("UPDATE t SET count = 99 WHERE id = 1"); err != nil {
+		t.Fatalf("UPDATE count: %v", err)
+	}
+	if _, err := db.Exec("DELETE FROM t WHERE count = 7"); err != nil {
+		t.Fatalf("DELETE count: %v", err)
+	}
+}

+ 16 - 5
DB/driver/parser.go

@@ -355,7 +355,6 @@ var keywords = map[string]bool{
 	"CREATE": true, "TABLE": true, "DROP": true, "IF": true, "EXISTS": true,
 	"PRIMARY": true, "KEY": true, "UNIQUE": true,
 	"INDEX": true, "ON": true,
-	"COUNT": true, "SUM": true, "MIN": true, "MAX": true, "AVG": true,
 	"AS": true,
 	"ALTER": true, "ADD": true, "COLUMN": true,
 	"SHOW": true, "DESCRIBE": true, "FULL": true, "TABLES": true, "COLUMNS": true,
@@ -518,7 +517,13 @@ func parse(sqlText string) (*statement, error) {
 	return st, nil
 }
 
-func (p *parser) peek() token  { return p.toks[p.pos] }
+func (p *parser) peek() token    { return p.toks[p.pos] }
+func (p *parser) peekAt(n int) token {
+	if p.pos+n < len(p.toks) {
+		return p.toks[p.pos+n]
+	}
+	return token{kind: tkEOF}
+}
 func (p *parser) next() token  { t := p.toks[p.pos]; p.pos++; return t }
 func (p *parser) backup()      { p.pos-- }
 
@@ -650,8 +655,11 @@ func (p *parser) parseSelect() (*statement, error) {
 		return &statement{kind: kSelect, sel: sel}, nil
 	}
 
-	// SELECT 列表: * / t.* / 列名 / t.列名 (可混用); 聚合单行另走分支
-	if p.peek().kind == tkKeyword && isAggFn(p.peek().text) {
+	// SELECT 列表: * / t.* / 列名 / t.列名 (可混用); 聚合单行另走分支。
+	// 聚合函数名 (COUNT/SUM/...) 不是保留字 — 列名可以叫 count (GORM 模型常见);
+	// 仅当名字后紧跟 '(' 才按聚合解析, 否则就是普通列引用。ident 保留原文大小写,
+	// 判定统一转大写 (GORM 生成的小写 count(*) 也要认)。
+	if p.peek().kind == tkIdent && isAggFn(strings.ToUpper(p.peek().text)) && p.peekAt(1).kind == tkPunct && p.peekAt(1).text == "(" {
 		agg, err := p.parseAgg()
 		if err != nil {
 			return nil, err
@@ -798,7 +806,10 @@ func isAggFn(w string) bool {
 }
 
 func (p *parser) parseAgg() (*aggSpec, error) {
-	fn := p.next().text
+	// ident 保留原文大小写 (count / COUNT 都要认), 函数名归一成上限 —
+	// 下游 queryAggregate 按 "COUNT"/"SUM"/... 大写匹配, MIN/MAX 的结果列名
+	// 也要这个形态。
+	fn := strings.ToUpper(p.next().text)
 	spec := &aggSpec{fn: fn}
 	if err := p.expectPunct("("); err != nil {
 		return nil, err

+ 15 - 6
docs/SQL_DRIVER_GUIDE.md

@@ -105,8 +105,8 @@ DROP TABLE [IF EXISTS] t
 | `DOUBLE` `FLOAT` | `Float64/32` | |
 | `BOOL` `BOOLEAN` | `Bool` | |
 | `VARCHAR(n)` `CHAR(n)` | `String`(定长档) | n ≤ 2048;超出请用 TEXT |
-| `TEXT` | 动态列 | 不可建索引 |
-| `BLOB` | Blob 列 | 行内 8B 引用,SELECT 出见限制 |
+| `TEXT` | 动态列 | 不可建索引;单值上限 16360 字节,超出请用 BLOB |
+| `BLOB` | Blob 列 | 行内 8B 引用 + 链页(单值 ~4GB);SELECT 自动物化读出内容字节 |
 | `DECIMAL(p,s)` | `Decimal` | s 落列级参数 |
 | `DATETIME` `TIMESTAMP` | `Uint64` | 约定:毫秒时间戳 |
 | `UUID` | `UUID` | |
@@ -153,8 +153,15 @@ DROP TABLE [IF EXISTS] t
 ## 已知限制
 
 - SELECT 结果集**全量物化**进内存(大结果集占用内存,后续可换流式)。
-- **BLOB 列不支持 SELECT 出**(行内只有引用),返回明确错误;请用引擎
-  Blob API 读取。
+- **动态列单值上限 16360 字节**(`types.MaxStringSize`):TEXT 列的内容整块落进一个
+  堆页,更大的值写入即报错。两条出路:新表大文本直接建模成 BLOB 列;存量库走
+  `migrate` 迁移时,采样发现 TEXT 源列超过该上限会**自动落成 Blob 链式列**(行内
+  8B 引用 + 链页,单值 ~4GB),并在迁移报告里写明。
+- **BLOB 列 SELECT 自动物化**:行内只有 8B 链引用,驱动在持锁期间读链页把内容
+  字节放进结果集(单表流式与 JOIN 两条路径都接上了),`Scan` 到 `[]byte`/`string`
+  均可;压缩标志不持久化,驱动按不压缩落链、同值读出(聚合 `MIN/MAX` 命中 BLOB
+  列仍明确报错)。引擎层面空 blob 与 NULL 同态(行内位图承载 NULL,空引用视为
+  "读不到"),读出均为 NULL。
 - 时间值约定:驱动与 gormvrtx 的 `DATETIME` 列是引擎的 `DateTime` 类型
   (行内 uint64 毫秒);GORM 把时间值内联成 SQL 字面量传入,驱动会解析
   `'YYYY-MM-DD HH:MM:SS[.fff]'` 形式,**零时间(`0001-01-01...`)按 NULL
@@ -181,8 +188,10 @@ db, err := gorm.Open(vrtx.Open("/path/to/data.db"), &gorm.Config{})
 - **AutoMigrate 真正可用**:`gormvrtx` 实现了 GORM 的 Migrator 接口,
   schema 自省与变更**直连引擎元数据 API**(不经 SQL):建表 / 加列 /
   删列 / 唯一与普通索引的自动比对与补齐都能跑通(`AlterColumn` 改类型、
-  重命名等少数操作返回"不支持")。带索引标签的字符串字段若会落成动态列
-  (无 size 或 size>2048),建表阶段即给出可操作的中文提示。
+  重命名等少数操作返回"不支持")。**无 size 的 string 字段默认落成 BLOB 链式列**
+  (不定长大文本不再撞 16360 上限;代价是失去 LIKE/索引能力,需要可搜索文本时
+  给 `size:1..2048` 走定长档);带索引标签却落成不可索引列(动态列 /
+  Blob 列)的字段,建表阶段即给出可操作的中文提示。
 - 整型主键零值自动预分配并回填对象(对齐自增语义)。
 - **版本与 `Save`**:按 gorm.io/gorm v1.31 开发验证;`Save()` 在 v1.31
   生成 `INSERT ... ON CONFLICT` upsert 语法,驱动不支持(明确报错),

+ 162 - 0
gormvrtx/blob_text_test.go

@@ -0,0 +1,162 @@
+package gormvrtx
+
+// 无 size string 字段的 Blob 链式列归宿: 动态列单值上限 16360 字节, 不定长大文本
+// 运行时会撞墙; Blob 列行内 8B 引用 + 链页 (~4GB), 驱动 SELECT 物化后 GORM 照常
+// 扫进 string。这里钉住三件事: AutoMigrate 的列类型、60KB 值的 Create/First 往返、
+// 显式 type:text 的同归宿与带索引标签时的友好报错。
+
+import (
+	"strings"
+	"testing"
+
+	"gorm.io/gorm"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+// Article 正文模型: Content 无 size → AutoMigrate 应落成 Blob 链式列。
+type Article struct {
+	ID      uint   `gorm:"primaryKey"`
+	Title   string `gorm:"size:64;not null"`
+	Content string // 无 size: 不定长大文本
+}
+
+func (Article) TableName() string { return "articles" }
+
+// Memo 显式 type:text —— 与无 size string 同一归宿 (Blob 链式列)。
+type Memo struct {
+	ID      uint   `gorm:"primaryKey"`
+	Content string `gorm:"type:text"`
+}
+
+func (Memo) TableName() string { return "memos" }
+
+// TestStringNoSizeAutoMigrateToBlob 无 size string 字段 (含显式 type:text)
+// AutoMigrate 后必须是 Blob 列; DataTypeOf 对两类字段都报 BLOB; 带 size 的照旧定长。
+func TestStringNoSizeAutoMigrateToBlob(t *testing.T) {
+	db := openTest(t)
+	if err := db.AutoMigrate(&Article{}, &Memo{}); err != nil {
+		t.Fatalf("AutoMigrate: %v", err)
+	}
+
+	d := db.Dialector.(*Dialector)
+	fieldType := func(table, col string) types.FieldType {
+		t.Helper()
+		tbl, ok := d.db.FindTable(table)
+		if !ok {
+			t.Fatalf("表 %s 不存在", table)
+		}
+		for i := range tbl.Field {
+			f := &tbl.Field[i]
+			if !f.IsDropped() && strings.EqualFold(string(f.FieldName), col) {
+				return f.FieldType
+			}
+		}
+		t.Fatalf("表 %s 没有列 %s", table, col)
+		return 0
+	}
+
+	if got := fieldType("articles", "content"); got != types.FieldType_Blob {
+		t.Fatalf("无 size 的 content 列应落成 FieldType_Blob, 实际 %v", got)
+	}
+	if got := fieldType("articles", "title"); got != types.FieldType_String {
+		t.Fatalf("size:64 的 title 列应落成 FieldType_String, 实际 %v", got)
+	}
+	if got := fieldType("memos", "content"); got != types.FieldType_Blob {
+		t.Fatalf("显式 type:text 的 content 列应落成 FieldType_Blob, 实际 %v", got)
+	}
+
+	// Migrator 自省 (ColumnTypes) 与 DataTypeOf 同口径对外。
+	cols, err := db.Migrator().ColumnTypes(&Article{})
+	if err != nil {
+		t.Fatalf("ColumnTypes: %v", err)
+	}
+	for _, c := range cols {
+		switch c.Name() {
+		case "content":
+			if c.DatabaseTypeName() != "blob" {
+				t.Fatalf("content 的 DatabaseTypeName 应为 blob, 实际 %q", c.DatabaseTypeName())
+			}
+		case "title":
+			if c.DatabaseTypeName() != "string" {
+				t.Fatalf("title 的 DatabaseTypeName 应为 string, 实际 %q", c.DatabaseTypeName())
+			}
+			if l, ok := c.Length(); !ok || l != 64 {
+				t.Fatalf("title 的长度应为 64, 实际 %d (%v)", l, ok)
+			}
+		}
+	}
+
+	stmt := &gorm.Statement{DB: db}
+	if err := stmt.Parse(&Article{}); err != nil {
+		t.Fatalf("Parse: %v", err)
+	}
+	if got := db.Migrator().FullDataTypeOf(stmt.Schema.FieldsByDBName["content"]).SQL; got != "BLOB" {
+		t.Fatalf("DataTypeOf(content) = %q, 想要 BLOB", got)
+	}
+	if err := stmt.Parse(&Memo{}); err != nil {
+		t.Fatalf("Parse: %v", err)
+	}
+	if got := db.Migrator().FullDataTypeOf(stmt.Schema.FieldsByDBName["content"]).SQL; !strings.HasPrefix(got, "BLOB") {
+		t.Fatalf("DataTypeOf(type:text content) = %q, 想要 BLOB 开头", got)
+	}
+}
+
+// TestBlobContentRoundTrip 60KB 正文 (超动态列单值上限 16360) Create 进去、
+// First 读回来逐字节一致 —— 链式写入与 SELECT 物化全链路。
+func TestBlobContentRoundTrip(t *testing.T) {
+	db := openTest(t)
+	if err := db.AutoMigrate(&Article{}); err != nil {
+		t.Fatalf("AutoMigrate: %v", err)
+	}
+
+	content := strings.Repeat("正文字段往返验证-0123456789abcdef", 2200) // >16360 字节
+	a := &Article{Title: "deep-dive", Content: content}
+	if err := db.Create(a).Error; err != nil {
+		t.Fatalf("Create (60KB blob): %v", err)
+	}
+	if a.ID == 0 {
+		t.Fatal("主键应回填")
+	}
+
+	var got Article
+	if err := db.First(&got, a.ID).Error; err != nil {
+		t.Fatalf("First: %v", err)
+	}
+	if got.Content != content {
+		t.Fatalf("60KB 正文往返不一致: got %d 字节, want %d 字节", len(got.Content), len(content))
+	}
+	if got.Title != "deep-dive" {
+		t.Fatalf("title = %q", got.Title)
+	}
+
+	// 按定长列过滤 + 只取部分行: blob 列不影响其它列的查询。
+	var hits []Article
+	if err := db.Where("title = ?", "deep-dive").Limit(1).Find(&hits).Error; err != nil {
+		t.Fatalf("Where: %v", err)
+	}
+	if len(hits) != 1 || hits[0].Content != content {
+		t.Fatalf("Where 命中行的正文不一致 (%d 行)", len(hits))
+	}
+}
+
+// ArticleBad 无 size 还带索引标签: 建表阶段必须给友好错误, 而不是引擎的裸拒绝。
+type ArticleBad struct {
+	ID      uint   `gorm:"primaryKey"`
+	Content string `gorm:"index"` // 无 size → Blob 列, 带索引意图
+}
+
+func (ArticleBad) TableName() string { return "article_bads" }
+
+func TestBlobColumnIndexIntentError(t *testing.T) {
+	db := openTest(t)
+	err := db.AutoMigrate(&ArticleBad{})
+	if err == nil {
+		t.Fatal("无 size 字符串字段带索引标签, AutoMigrate 应报友好错误")
+	}
+	for _, w := range []string{"Blob 链式列", "size:1..2048"} {
+		if !strings.Contains(err.Error(), w) {
+			t.Fatalf("错误里缺少 %q: %v", w, err)
+		}
+	}
+}

+ 29 - 13
gormvrtx/dialector.go

@@ -209,23 +209,35 @@ func explicitTypeMapping(tag string) (string, int, bool) {
 // 建表本身走引擎 API, 不经 SQL)。
 //
 // 显式 gorm:"type:..." 标签优先 (报告 #10 的逃生门: string 字段默认无 size 会落成
-// 动态列, 带索引时引擎拒绝建索引 —— 用 type:varchar(500) 精确声明即可绕过默认映射):
-// 按 explicitTypeMapping 解析, 认识 → 按其返回; 不认识 → 保持默认映射并附注释说明。
+// Blob 链式列, 带索引时引擎拒绝建索引 —— 用 type:varchar(500) 精确声明即可绕过
+// 默认映射): 按 explicitTypeMapping 解析, 认识 → 按其返回; 不认识 → 保持默认映射
+// 并附注释说明。
 // 默认映射: string 带尺寸 → VARCHAR(size), 超过引擎行内定长上限 2048 时截断声明并
-// 注释说明; 无尺寸 → TEXT (动态列)。
+// 注释说明; 无尺寸 → BLOB (链式存储)。
+//
+// 无尺寸 string 落 BLOB 而不是旧行为的 TEXT (动态列): 动态列单值上限 16360 字节
+// (types.MaxStringSize), 运行时写入更大的文本会撞墙; Blob 列行内 8B 引用 + 链页,
+// 单值上限约 4GB。读回时驱动把链内容物化成 []byte, GORM 照常扫进 string。
+// 权衡: 该列失去 LIKE 与索引能力 (行内只有引用, 按值查不到) —— 需要可搜索的大文本
+// 请给 size:1..2048 走定长档, 或把检索字段拆出来单独建列。
 func (d *Dialector) DataTypeOf(field *schema.Field) string {
 	if tag, ok := field.TagSettings["TYPE"]; ok {
 		if name, size, ok := explicitTypeMapping(tag); ok {
-			if name != "VARCHAR" {
-				return name
-			}
-			switch {
-			case size <= 0:
-				return "VARCHAR /* 声明无宽度, 建列按动态列处理 */"
-			case size > types.MaxInlineColumnWidth:
-				return fmt.Sprintf("VARCHAR(2048) /* 声明 %d 超引擎行内上限 2048, 截断 */", size)
+			switch name {
+			case "TEXT":
+				// 显式 type:text 与无 size 默认同一归宿: Blob 链式列 (权衡见上)。
+				return "BLOB /* 不定长大文本: 链式存储, 单值上限 ~4GB; 失去 LIKE/索引能力, 需要可搜索文本请用 size:1..2048 */"
+			case "VARCHAR":
+				switch {
+				case size <= 0:
+					return "VARCHAR /* 声明无宽度, 建列按动态列处理 */"
+				case size > types.MaxInlineColumnWidth:
+					return fmt.Sprintf("VARCHAR(2048) /* 声明 %d 超引擎行内上限 2048, 截断 */", size)
+				default:
+					return fmt.Sprintf("VARCHAR(%d)", size)
+				}
 			default:
-				return fmt.Sprintf("VARCHAR(%d)", size)
+				return name
 			}
 		}
 		return fmt.Sprintf("%s /* 未知 type:%s, 按 Go 类型默认映射 */", d.defaultDataTypeOf(field), tag)
@@ -234,6 +246,10 @@ func (d *Dialector) DataTypeOf(field *schema.Field) string {
 }
 
 // defaultDataTypeOf 按 Go 类型给默认 SQL 类型名 (DataTypeOf 的回落分支)。
+//
+// 无尺寸 string 落 BLOB (链式存储): 动态列单值上限 16360 字节, 不定长大文本运行时会
+// 撞墙; Blob 列行内 8B 引用 + 链页 (~4GB)。代价是失去 LIKE/索引能力 —— 需要可搜索
+// 文本时给 size:1..2048 走定长档。读回经驱动物化成 []byte, GORM 可扫进 string。
 func (d *Dialector) defaultDataTypeOf(field *schema.Field) string {
 	switch field.DataType {
 	case schema.Uint:
@@ -249,7 +265,7 @@ func (d *Dialector) defaultDataTypeOf(field *schema.Field) string {
 			}
 			return fmt.Sprintf("VARCHAR(%d)", field.Size)
 		}
-		return "TEXT"
+		return "BLOB"
 	case schema.Bool:
 		return "BOOLEAN"
 	case schema.Time:

+ 28 - 11
gormvrtx/migrator.go

@@ -80,8 +80,8 @@ func tableFieldIndex(tbl *core.Table, name string) int {
 }
 
 // fieldInfoOf GORM 字段 → 引擎列定义。显式 gorm:"type:..." 标签优先于 Go 类型默认
-// 映射 (报告 #10 的逃生门: string 字段默认无 size 会落成动态列, 带索引时引擎拒绝建
-// 索引 —— 用 type:varchar(500)/type:bigint 精确声明列类型/宽度即可绕过)。
+// 映射 (报告 #10 的逃生门: string 字段默认无 size 会落成 Blob 链式列, 带索引时引擎
+// 拒绝建索引 —— 用 type:varchar(500)/type:bigint 精确声明列类型/宽度即可绕过)。
 func (m Migrator) fieldInfoOf(f *schema.Field) (types.FieldInfo, error) {
 	fi := types.FieldInfo{FieldName: []byte(f.DBName)}
 	// 可空: NotNull==false 且非主键 → 允许 NULL。主键列是行定位键, 绝不可空。
@@ -105,8 +105,16 @@ func (m Migrator) fieldInfoOf(f *schema.Field) (types.FieldInfo, error) {
 			case "DATETIME":
 				fi.FieldType = types.FieldType_DateTime
 			case "TEXT":
-				fi.FieldType = types.FieldType_Dynamicbyte
+				// 显式 type:text 与无 size string 同一归宿: Blob 链式列 (权衡见
+				// stringFieldInfo) —— 动态列单值上限 16360, 不定长大文本运行时会撞墙。
+				fi.FieldType = types.FieldType_Blob
 			case "VARCHAR":
+				if size <= 0 {
+					// 显式 type:varchar 却没给宽度: 与 DataTypeOf 的注释同一口径,
+					// 按动态列处理 (无长度语义的文本), 不落到下面的 Blob 默认。
+					fi.FieldType = types.FieldType_Dynamicbyte
+					return fi, nil
+				}
 				return stringFieldInfo(fi, f.DBName, size)
 			}
 			return fi, nil
@@ -135,16 +143,21 @@ func (m Migrator) fieldInfoOf(f *schema.Field) (types.FieldInfo, error) {
 	return fi, nil
 }
 
-// stringFieldInfo string 字段按声明宽度落成引擎列: 无宽度或声明超行内定长上限
-// (types.MaxInlineColumnWidth = 2048) → 动态列 (TEXT); 其余定长 string 并写宽度码。
+// stringFieldInfo string 字段按声明宽度落成引擎列: 无宽度 → Blob 链式列; 声明超行内
+// 定长上限 (types.MaxInlineColumnWidth = 2048) → 动态列; 其余定长 string 并写宽度码。
 // 宽度声明兑现不了 (引擎档位限制) 返回错误。
+//
+// 无宽度落 Blob 而不是旧行为的动态列: 动态列单值上限 types.MaxStringSize (16360 字节,
+// 内容整块落一个堆页), 不定长大文本写入超限即报错; Blob 列行内 8B BlobRef + 链页,
+// 单值约 4GB —— 这正是 migrate 对超宽 TEXT 源列的自动归宿。读回时驱动把链内容物化
+// 成 []byte, GORM 可扫进 string。代价: 该列失去 LIKE/索引能力 (行内只有引用)。
 func stringFieldInfo(fi types.FieldInfo, dbName string, size int) (types.FieldInfo, error) {
 	if size < 0 {
 		size = 0
 	}
 	switch {
 	case size == 0:
-		fi.FieldType = types.FieldType_Dynamicbyte // 无长度语义的文本
+		fi.FieldType = types.FieldType_Blob // 不定长大文本: 链式存储
 	case size > types.MaxInlineColumnWidth:
 		fi.FieldType = types.FieldType_Dynamicbyte // 声明超行内定长上限 → 动态列
 	default:
@@ -175,9 +188,10 @@ func hasIndexIntent(f *schema.Field) bool {
 	return f.Unique || f.TagSettings["INDEX"] != "" || f.TagSettings["UNIQUEINDEX"] != ""
 }
 
-// dynamicColumnErr 报告 #10 的友好错误: 字段带索引标签却落成动态列时, 在建表阶段就
-// 给出可操作的提示, 而不是等建索引时被引擎的 "动态列不能作为索引键" 顶回来。
-// 字段不带索引意图、或落成可索引类型时返回 nil。
+// dynamicColumnErr 报告 #10 的友好错误: 字段带索引标签却落成不可索引的列时, 在建表
+// 阶段就给出可操作的提示, 而不是等建索引时被引擎顶回来。不可索引的列有两种:
+// 动态列 (行内堆引用) 与 Blob 链式列 (行内 8B 链引用) —— 无 size 的 string 字段
+// 现在是后者的默认归宿。字段不带索引意图、或落成可索引类型时返回 nil。
 func (m Migrator) dynamicColumnErr(f *schema.Field) error {
 	if !hasIndexIntent(f) {
 		return nil
@@ -186,8 +200,11 @@ func (m Migrator) dynamicColumnErr(f *schema.Field) error {
 	if err != nil {
 		return err
 	}
-	if fi.FieldType == types.FieldType_Dynamicbyte {
-		return fmt.Errorf("字段 %s 带索引标签但会落成动态列(无 size 或 size>2048),无法建索引:请给该字段加 gorm:\"size:1..2048\"", f.Name)
+	switch fi.FieldType {
+	case types.FieldType_Dynamicbyte:
+		return fmt.Errorf("字段 %s 带索引标签但会落成动态列(size>2048),无法建索引:请给该字段加 gorm:\"size:1..2048\"", f.Name)
+	case types.FieldType_Blob:
+		return fmt.Errorf("字段 %s 带索引标签但会落成 Blob 链式列(无 size 字符串字段的默认归宿),无法建索引:需要可搜索的文本请给该字段加 gorm:\"size:1..2048\" 走定长档", f.Name)
 	}
 	return nil
 }

+ 5 - 4
migrate/run.go

@@ -152,10 +152,11 @@ func runTable(src Source, dst Sink, name string, o *Options) (TableReport, error
 	}
 	sc = sc.Clone() // 迁移会往 schema 里补列, 不能污染源端那份
 
-	// TEXT 采样定宽 (见 textwidth.go): 建目标表之前把"源声明 TEXT 家族"的动态列按
-	// 源端采样的实际最大长度改落成定长档。先于 prepareForVRTX / fitRowWidthForVRTX:
-	// 退化主键列要不要补索引、行宽闸门要不要把采样得过宽的列降回动态列, 都该看到
-	// 采样后的最终列宽。DryRun 不碰数据, 连采样也不做。
+	// TEXT 采样三态分流 (见 textwidth.go): 建目标表之前把"源声明 TEXT 家族"的动态列按
+	// 源端采样的实际最大长度分档 —— 够窄的落定长档, 中间的保持动态列, 超单值上限的落
+	// Blob 链式列。先于 prepareForVRTX / fitRowWidthForVRTX: 退化主键列要不要补索引、
+	// 行宽闸门要不要把采样得过宽的列降回动态列, 都该看到采样后的最终列宽。DryRun 不
+	// 碰数据, 连采样也不做。
 	textWidthNote := ""
 	if dst.Kind() == KindVRTX && o.TextWidthScan && !o.DryRun {
 		textWidthNote = scanTextWidths(src, sc, o)

+ 4 - 3
migrate/sqltype.go

@@ -23,9 +23,10 @@ import (
 //	UUID                       UUID      (16 字节 RFC4122)
 //	CHAR(n) / VARCHAR(n)       按字节容量取最近的宽度档 (16..2048, 超过则 dynamic)
 //	TEXT 家族 (TEXT/TINYTEXT/MEDIUMTEXT/LONGTEXT/CLOB/NCLOB)
-//	                           采样定宽 (默认, 见 Options.TextWidthScan): 建表前采样
-//	                           实际最大长度, 向上取 2 的幂 (32..2048) 落 VARCHAR 档,
-//	                           列因此可索引; 关掉开关或采样超 2048 才落 dynamic
+//	                           采样三态分流 (默认, 见 Options.TextWidthScan): 建表前采样
+//	                           实际最大长度, ≤2048 向上取 2 的幂 (32..2048) 落 VARCHAR 档
+//	                           (列因此可索引); ≤16360 (单值上限) 保持动态列; 超单值上限
+//	                           落 Blob 链式列 (否则写入时整表失败)
 //	JSON / 其它无 VRTX 等价物   dynamic   (字节原样存, 不解释内容)
 //	BLOB / BINARY              blob      (同为动态列, 但语义是"文件/二进制大对象")
 //	DATE / DATETIME / TIMESTAMP datetime  (引擎专用时间类型, Unix 毫秒, 见 inferSQLType 的说明)

+ 62 - 24
migrate/textwidth.go

@@ -8,9 +8,11 @@ package migrate
 // 几十几百字节的短文本 (备注 / 状态 / 标签), 白白失去索引能力。
 //
 // 【处理】Options.TextWidthScan (默认开) 让迁移在建目标表之前先【采样】: 对源表跑一次
-// SELECT MAX(LENGTH(col)) (同表多列合一句, 每表一次), 按采样到的实际最大长度向上取
-// 2 的幂 (下限 textWidthMin, 上限 types.MaxInlineColumnWidth) 落成定长 VARCHAR 档;
-// 采样超过 2048 字节的列保持动态列。定宽列可建索引、读不走堆。
+// SELECT MAX(LENGTH(col)) (同表多列合一句, 每表一次), 按采样到的实际最大长度三态分流:
+// 不超过 types.MaxInlineColumnWidth (2048) 的向上取 2 的幂 (下限 textWidthMin) 落成
+// 定长 VARCHAR 档 (可索引、读不走堆); 超过 2048 但不超过单值上限 types.MaxStringSize
+// (16360) 的保持动态列; 超过单值上限的落 Blob 链式列 (行内 8B 引用 + 链页, 上限约
+// 4GB) —— 动态列按单块上限拒写, 保持动态列会在【写入时】整表失败, 落 Blob 才能迁得动。
 //
 // 【权衡】采样反映的是【当下】的数据: 定宽列会约束未来写入 (超宽即报错), 2 次幂圆整
 // 最多浪费一倍行内空间; 且采样按表做一次, 行数大时是一次额外的全表扫描。在意这些就
@@ -45,8 +47,9 @@ type TextWidthSampler interface {
 }
 
 // textWidthMin 采样定宽的档位下限 (字节)。TEXT 列哪怕当前全空也落 32 档而不是 16 —
-// 16 字节装不下一个像样的人名/备注, 而 32 只多付 16 字节; 上限就是行内宽度上限
-// types.MaxInlineColumnWidth (2048): 采样超过它只能保持动态列。
+// 16 字节装不下一个像样的人名/备注, 而 32 只多付 16 字节; 定长档的上限就是行内宽度
+// 上限 types.MaxInlineColumnWidth (2048), 再往上是动态列 / Blob 链式列的两档分流
+// (见 textWidthCode)。
 const textWidthMin = 32
 
 // isTextFamilyDecl 源声明是否 TEXT 家族 (TEXT/TINYTEXT/MEDIUMTEXT/LONGTEXT/CLOB/NCLOB)。
@@ -64,28 +67,48 @@ func isTextFamilyDecl(decl string) bool {
 	return false
 }
 
-// textWidthCode 采样最大长度 → 定长档位: 大于等于 maxLen 的最小 2 次幂 (下限
-// textWidthMin, 上限 types.MaxInlineColumnWidth), 返回 (宽度码, 档位宽度, 可否定宽);
-// maxLen 超上限返回 ok=false —— 那一列只能保持动态列。
+// textWidthClass 采样最大长度的三态归宿。
+type textWidthClass int
+
+const (
+	// widthToFixed: ≤ MaxInlineColumnWidth, 落成定长 VARCHAR 档。
+	widthToFixed textWidthClass = iota
+	// widthToDynamic: 定长档装不下但还在动态列单值上限内, 保持动态列。
+	widthToDynamic
+	// widthToBlob: 超过动态列单值上限 (types.MaxStringSize), 落 Blob 链式列。
+	widthToBlob
+)
+
+// textWidthCode 采样最大长度 → (宽度码, 档位宽度, 三态归宿):
+//
+//	maxLen ≤ MaxInlineColumnWidth              → 定长档: 大于等于 maxLen 的最小 2 次幂
+//	             (下限 textWidthMin) 的宽度码;
+//	MaxInlineColumnWidth < maxLen ≤ MaxStringSize → 动态列 (单值仍装得下, 只是不可定宽);
+//	maxLen > MaxStringSize                     → Blob 链式列 (动态列按单块上限拒写,
+//	                                             保持动态列会在写入时失败)。
 //
 // 2 的幂档 (32/64/.../2048) 全部精确落在引擎的 stringWidthTable 上, 所以
 // types.WidthCodeFor 正好命中 (不会向上漂到 48/96 这种中间档): "采样 100 字节 →
 // 128 档"是精确陈述, 不是约数。
-func textWidthCode(maxLen int64) (code byte, width int, ok bool) {
-	if maxLen > types.MaxInlineColumnWidth {
-		return 0, 0, false
+func textWidthCode(maxLen int64) (code byte, width int, class textWidthClass) {
+	switch {
+	case maxLen > types.MaxStringSize:
+		return 0, 0, widthToBlob
+	case maxLen > types.MaxInlineColumnWidth:
+		return 0, 0, widthToDynamic
 	}
 	w := textWidthMin
 	for int64(w) < maxLen {
 		w *= 2
 	}
-	return types.WidthCodeFor(w), w, true
+	return types.WidthCodeFor(w), w, widthToFixed
 }
 
 // scanTextWidths TEXT 采样定宽主逻辑, 建目标表之前调用: 找出 schema 里"源声明
-// TEXT 家族、当前推断为动态列"的列, 让源端按表采样一次, 够窄的改落成
-// FieldType_String + 宽度码 (可索引), 超宽的保持动态列。表结构就地修改, 返回写进
-// 迁移报告的说明 (无命中列时返回 "")。
+// TEXT 家族、当前推断为动态列"的列, 让源端按表采样一次, 按三态归宿改类型 (见
+// textWidthCode): 够窄的改落 FieldType_String + 宽度码 (可索引), 中间的保持动态列,
+// 超过动态列单值上限的落 FieldType_Blob (链式存储, 否则写入时必败)。表结构就地修改,
+// 返回写进迁移报告的说明 (无命中列时返回 "")。
 //
 // 放在 prepareForVRTX / fitRowWidthForVRTX 【之前】: 原主键列退化成普通列时要不要
 // 补索引 (ensureColumnIndex 只给非动态列补), 以及行宽闸门要不要把采样得过宽的列
@@ -115,23 +138,34 @@ func scanTextWidths(src Source, sc *TableSchema, o *Options) string {
 		o.logf("[migrate] 表 %s 的 TEXT 采样失败, 保持动态列 (旧行为): %v", sc.Name, err)
 		return appendNote(fmt.Sprintf("text 采样失败保持动态列 (%v)", err), scanNumericDomain(src, sc, o))
 	}
-	var fixed, kept []string
+	var fixed, kept, blobbed []string
 	for i := range sc.Columns {
 		c := &sc.Columns[i]
 		if c.Type != types.FieldType_Dynamicbyte || !isTextFamilyDecl(c.Decl) {
 			continue
 		}
 		maxLen := maxLens[c.Name] // 缺项视为 0, 全 NULL 的列落最窄档
-		code, width, ok := textWidthCode(maxLen)
-		if !ok {
+		code, width, class := textWidthCode(maxLen)
+		switch class {
+		case widthToBlob:
+			// 采样值超过动态列单值上限: 保持动态列只会在【写入时】被引擎按单块上限
+			// 拒掉 (整表迁移失败, 见 DockerManager 实测的 sys_logs.param 59566 字节),
+			// 落 Blob 链式列才有出路: 行内 8B BlobRef, 内容可跨页, 单值约 4GB。
+			// 代价照实报: 该列失去索引能力 (源列在 TEXT 上的索引会被 CreateTable 跳过)。
+			c.Type = types.FieldType_Blob
+			c.Param = 0
+			blobbed = append(blobbed, fmt.Sprintf("%s (%d 字节)", c.Name, maxLen))
+			o.vlogf("[migrate] 表 %s 的 TEXT 列 %s 采样最大 %d 字节, 超动态列单值上限 %d, 落 Blob 链式列",
+				sc.Name, c.Name, maxLen, types.MaxStringSize)
+		case widthToDynamic:
 			kept = append(kept, fmt.Sprintf("%s 超 %d 字节", c.Name, types.MaxInlineColumnWidth))
-			continue
+		default:
+			c.Type = types.FieldType_String
+			c.Param = code
+			fixed = append(fixed, fmt.Sprintf("%s %d 字节", c.Name, width))
+			o.vlogf("[migrate] 表 %s 的 TEXT 列 %s 采样最大 %d 字节, 定宽 %d 字节",
+				sc.Name, c.Name, maxLen, width)
 		}
-		c.Type = types.FieldType_String
-		c.Param = code
-		fixed = append(fixed, fmt.Sprintf("%s %d 字节", c.Name, width))
-		o.vlogf("[migrate] 表 %s 的 TEXT 列 %s 采样最大 %d 字节, 定宽 %d 字节",
-			sc.Name, c.Name, maxLen, width)
 	}
 	var note string
 	if len(fixed) > 0 {
@@ -140,6 +174,10 @@ func scanTextWidths(src Source, sc *TableSchema, o *Options) string {
 	if len(kept) > 0 {
 		note = appendNote(note, "text 列采样超宽保持动态 ("+strings.Join(kept, ", ")+")")
 	}
+	if len(blobbed) > 0 {
+		note = appendNote(note, fmt.Sprintf("text 列超 %d 字节落 Blob 链式列 (%s)",
+			types.MaxStringSize, strings.Join(blobbed, ", ")))
+	}
 	return appendNote(note, scanNumericDomain(src, sc, o))
 }
 

+ 181 - 17
migrate/textwidth_test.go

@@ -8,6 +8,7 @@ import (
 	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/util"
 	"database/sql"
+	"fmt"
 	"path/filepath"
 	"strings"
 	"testing"
@@ -265,32 +266,37 @@ func TestTextWidthScanDisabled(t *testing.T) {
 	}
 }
 
-// TestTextWidthCode 宽度规则本身: 大于等于 maxLen 的最小 2 次幂, 下限 32, 上限 2048,
-// 超过 2048 不可定宽。档位必须精确命中 (WidthCodeWidth 就是 2 次幂本身, 不是 224 之类)。
+// TestTextWidthCode 宽度规则本身: 大于等于 maxLen 的最小 2 次幂, 下限 32, 上限 2048;
+// 2048 < maxLen ≤ MaxStringSize 保持动态列; maxLen > MaxStringSize 落 Blob 链式列。
+// 档位必须精确命中 (WidthCodeWidth 就是 2 次幂本身, 不是 224 之类)。
 func TestTextWidthCode(t *testing.T) {
 	cases := []struct {
 		maxLen    int64
 		wantWidth int
-		wantOK    bool
+		wantClass textWidthClass
 	}{
-		{0, 32, true},   // 全 NULL / 空表
-		{1, 32, true},   // 下限 32: 任何非空值至少 32 档
-		{16, 32, true},  // 16 字节也抬到 32 (下限)
-		{32, 32, true},
-		{33, 64, true},
-		{100, 128, true},
-		{2048, 2048, true},
-		{2049, 0, false}, // 超上限: 保持动态列
-		{5000, 0, false},
+		{0, 32, widthToFixed},   // 全 NULL / 空表
+		{1, 32, widthToFixed},   // 下限 32: 任何非空值至少 32 档
+		{16, 32, widthToFixed},  // 16 字节也抬到 32 (下限)
+		{32, 32, widthToFixed},
+		{33, 64, widthToFixed},
+		{100, 128, widthToFixed},
+		{2048, 2048, widthToFixed},
+		{2049, 0, widthToDynamic}, // 超定宽上限: 保持动态列
+		{5000, 0, widthToDynamic},
+		{16360, 0, widthToDynamic}, // 正好顶到单值上限: 动态列仍装得下
+		{16361, 0, widthToBlob},    // 超单值上限: 落 Blob 链式列
+		{60000, 0, widthToBlob},
+		{1 << 30, 0, widthToBlob},
 	}
 	for _, tc := range cases {
-		code, width, ok := textWidthCode(tc.maxLen)
-		if ok != tc.wantOK || width != tc.wantWidth {
-			t.Errorf("textWidthCode(%d) = (码 %d, 宽 %d, ok %v), 期望 (宽 %d, ok %v)",
-				tc.maxLen, code, width, ok, tc.wantWidth, tc.wantOK)
+		code, width, class := textWidthCode(tc.maxLen)
+		if class != tc.wantClass || width != tc.wantWidth {
+			t.Errorf("textWidthCode(%d) = (码 %d, 宽 %d, 态 %d), 期望 (宽 %d, 态 %d)",
+				tc.maxLen, code, width, class, tc.wantWidth, tc.wantClass)
 			continue
 		}
-		if ok && int(types.WidthCodeWidth(code)) != width {
+		if class == widthToFixed && int(types.WidthCodeWidth(code)) != width {
 			t.Errorf("textWidthCode(%d): 宽度码 %d 对应 %d 字节, 与档位 %d 不符 (没有精确命中)",
 				tc.maxLen, code, types.WidthCodeWidth(code), width)
 		}
@@ -356,3 +362,161 @@ func TestTextWidthsOneQueryPerTable(t *testing.T) {
 	}
 }
 
+
+// TestTextWidthOversizeFallsToBlob 采样超过动态列单值上限 (types.MaxStringSize, 16360)
+// 的 TEXT 列不再"保持动态列、写入时被引擎按单块上限拒掉整表", 而是落成 Blob 链式列:
+// 60KB 的值逐字节迁进 VRTX (经引擎 BlobRead 对照), 再逐字节迁回 SQLite —— 复刻
+// DockerManager 实测的失败场景 (sys_logs.param 59566 字节)。
+func TestTextWidthOversizeFallsToBlob(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "src.sqlite")
+	midPath := filepath.Join(dir, "mid.db")
+	backPath := filepath.Join(dir, "back.sqlite")
+
+	big := strings.Repeat("0123456789abcdef", 3750) // 60000 字节, 超 16360 上限
+
+	db, err := sql.Open("sqlite", srcPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, s := range []string{
+		`CREATE TABLE sys_logs (
+			id     INTEGER PRIMARY KEY,
+			action VARCHAR(32),
+			param  TEXT
+		)`,
+		`INSERT INTO sys_logs (id, action, param) VALUES (1, 'deploy', ?)`,
+	} {
+		if _, err := db.Exec(s, big); err != nil {
+			t.Fatalf("执行 %s: %v", s, err)
+		}
+	}
+	// 同一列再补一行短值 + 一行 NULL: 落 Blob 后小值与 NULL 的读写也必须正常。
+	if _, err := db.Exec(`INSERT INTO sys_logs (id, action, param) VALUES (2, 'ping', 'ok')`); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := db.Exec(`INSERT INTO sys_logs (id, action, param) VALUES (3, 'noop', NULL)`); err != nil {
+		t.Fatal(err)
+	}
+	_ = db.Close()
+
+	src, err := OpenSQLite(srcPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	mid, err := OpenVRTX(midPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	rep, err := Run(src, mid, quiet())
+	if err != nil {
+		t.Fatalf("SQLite→VRTX (60KB TEXT 应自动落 Blob 迁过去): %v", err)
+	}
+	_ = src.Close()
+	_ = mid.Close()
+
+	// 列类型: 必须是 Blob 链式列, 而不是保持动态列等写入时失败。
+	f := vrtxColumn(t, midPath, "sys_logs", "param")
+	if f.FieldType != types.FieldType_Blob {
+		t.Fatalf("采样超单值上限的列 param 应落 FieldType_Blob, 实际 %v", f.FieldType)
+	}
+
+	// 内容逐字节对照 (不经 migrate 读端, 直接开引擎文件读链页, 免得"两端同一段代码"
+	// 把不对称的错抹平): 快照 norm 用既有 bytes:%x 形态。
+	vdb, err := core.Open(midPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	vtbl, ok := vdb.FindTable("sys_logs")
+	if !ok {
+		t.Fatal("mid.db 里没有 sys_logs")
+	}
+	got := map[uint64]string{}
+	err = vtbl.Query().ScanWithPKID(func(pkID uint64, vals []types.FieldValue) bool {
+		// NULL / 空 blob 的行内引用读侧一律"读不到" (位图置位 / 空引用跳过),
+		// 缺该列即 NULL; 先按 NULL 预置, 见到值再覆盖。
+		got[pkID] = "NULL"
+		for _, fv := range vals {
+			if util.BytesToString(fv.FieldName) != "param" {
+				continue
+			}
+			ref, ok := fv.Value.(types.BlobRef)
+			if !ok {
+				t.Fatalf("param 的行内值应是 BlobRef, 实际 %T", fv.Value)
+			}
+			data, err := vtbl.BlobRead(ref, types.CompressNone)
+			if err != nil {
+				t.Fatalf("读回 param 的 blob 链: %v", err)
+			}
+			got[pkID] = fmt.Sprintf("bytes:%x", data)
+		}
+		return true
+	})
+	_ = vdb.Close()
+	if err != nil {
+		t.Fatal(err)
+	}
+	wantBig := fmt.Sprintf("bytes:%x", []byte(big))
+	if got[1] != wantBig {
+		t.Errorf("60KB 值迁过去后内容变了: 长度 got=%d want=%d", len(got[1]), len(wantBig))
+	}
+	if got[2] != fmt.Sprintf("bytes:%x", []byte("ok")) {
+		t.Errorf("短值行 param = %q", got[2])
+	}
+	if got[3] != "NULL" {
+		t.Errorf("NULL 行 param = %q, 想要 NULL", got[3])
+	}
+
+	// 报告: 落 Blob 必须可见。
+	if len(rep.Tables) != 1 {
+		t.Fatalf("应有 1 张表的报告, 实际 %d", len(rep.Tables))
+	}
+	note := rep.Tables[0].Note
+	for _, w := range []string{"落 Blob 链式列", "60000 字节", "param"} {
+		if !strings.Contains(note, w) {
+			t.Errorf("报告 Note 里缺少 %q: %q", w, note)
+		}
+	}
+
+	// 反向再迁一轮 (VRTX → SQLite): Blob 列经 migrate 读端解引用、按 BLOB 列建表,
+	// 内容必须逐字节回来。
+	vmid, err := OpenVRTX(midPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	back, err := OpenSQLite(backPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := Run(vmid, back, quiet()); err != nil {
+		t.Fatalf("VRTX→SQLite: %v", err)
+	}
+	_ = vmid.Close()
+	_ = back.Close()
+
+	bdb, err := sql.Open("sqlite", backPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer bdb.Close()
+	var backBig, backShort []byte
+	var backNull sql.NullString
+	if err := bdb.QueryRow(`SELECT param FROM sys_logs WHERE id = 1`).Scan(&backBig); err != nil {
+		t.Fatal(err)
+	}
+	if fmt.Sprintf("bytes:%x", backBig) != wantBig {
+		t.Errorf("迁回 SQLite 的 60KB 值逐字节不一致 (got %d 字节)", len(backBig))
+	}
+	if err := bdb.QueryRow(`SELECT param FROM sys_logs WHERE id = 2`).Scan(&backShort); err != nil {
+		t.Fatal(err)
+	}
+	if string(backShort) != "ok" {
+		t.Errorf("短值行迁回 = %q", backShort)
+	}
+	if err := bdb.QueryRow(`SELECT param FROM sys_logs WHERE id = 3`).Scan(&backNull); err != nil {
+		t.Fatal(err)
+	}
+	if backNull.Valid {
+		t.Errorf("NULL 行迁回应是 NULL, 实际 %q", backNull.String)
+	}
+}

+ 5 - 3
migrate/types.go

@@ -509,9 +509,11 @@ type Options struct {
 	TruncateOversize bool
 	// TextWidthScan 目标端是 VRTX 时, 对源库声明为 TEXT 家族 (TEXT/TINYTEXT/MEDIUMTEXT/
 	// LONGTEXT/CLOB/NCLOB) 且无显式宽度的字符列做【采样定宽】: 建目标表前对源表跑一次
-	// SELECT MAX(LENGTH(col)) (同表多列合一句, 每表一次), 按采样到的实际最大长度向上取
-	// 2 的幂 (下限 32, 上限 2048 字节) 落成定长 VARCHAR 档 —— 定宽列可建索引、读不走堆;
-	// 采样超过 2048 字节才保持动态列。
+	// SELECT MAX(LENGTH(col)) (同表多列合一句, 每表一次), 按采样到的实际最大长度三态分流:
+	// 不超过 2048 字节的向上取 2 的幂 (下限 32) 落成定长 VARCHAR 档 —— 定宽列可建索引、
+	// 读不走堆; 超过 2048 但不超过动态列单值上限 (types.MaxStringSize, 16360) 的保持
+	// 动态列; 超过单值上限的落 Blob 链式列 (行内 8B 引用 + 链页, 上限约 4GB) —— 动态列
+	// 按单块上限拒写, 不转 Blob 这类大值会在写入时让整表迁移失败。
 	//
 	// 权衡: 采样反映的是【当下】的数据 —— 定宽列会约束未来写入 (超宽即报错), 2 次幂
 	// 圆整最多浪费一倍行内空间; 且采样按表做一次, 行数大时是一次额外的全表扫描。