Files
svfs/src/sqlite_vfs/cli/publish.py
T
chakcy ecdfacc620 fix+feat: 修复打包/解包主链路,并把存储层重建为内容寻址 + 快照
起始状态:主链路 100% 不可用,而且失败被静默吞掉——打包后库里 0 个文件、
界面仍打印"文件数量: N";解包导出 0 个文件并在磁盘上生成几百层 sub/sub/sub 垃圾目录。

缺陷修复(4 个 P0 + 12 个 P1,逐条见 BUG_REPORT.md):
- 建表列名 compresse_algo 与 INSERT/read_file 用的 compress_algo 不一致
- list_directory 按 path 而非 parent_path 过滤,导致导出无限自递归
- CLI --ignore-timestamps 把 export_filesystem 赋成引用自身的 lambda
- add_file 缺少 commit(直接调用 API 时静默丢数据)
- LIKE 前缀误伤兄弟目录(删 doc 连带删 docs);mv_dir 二次改名
- 失败路径一律返回 exit code 0;--exclude 是子串匹配而非 glob
- FolderPacker.add_file 反向调用 export_file_safe,会用库内内容覆盖源文件

存储层(schema v3,格式变更,提供 vfsmigrate 迁移):
- 内容移入 blobs(hash PRIMARY KEY):相同内容只存一份,跨路径/跨快照共享
- files 的唯一约束改为 UNIQUE(snapshot_id, path):一个库存多个版本
- 完整性:每文件 sha256 + 快照级摘要 + HMAC-SHA256 签名,只读连接即可验证
- 增量打包:按 mtime+size 或 sha256 只写变化项,未变文件不重写不重压
- get_file_info 不再返回 content(元数据查询不该搬 BLOB)

新增能力:
- 可选 FastAPI 静态文件插件(extras,核心仍零第三方依赖)
- 内容寻址发布到对象存储 + 逐对象校验的取回客户端
- 压缩默认改回 zlib:实测比自研哈夫曼体积小 1/3~1/170、快数百倍

其它:
- P2-4:时间戳显式转字符串,清掉 1240 条 DeprecationWarning(盘上格式不变)
- read_file 负数 offset/size 改为报错(原先会走切片语义从末尾取)
- requires-python 从 >=3.7 改为按实际 API 推导的 >=3.9

测试:0 → 343 个用例,纯标准库 unittest,有/无 FastAPI 两种环境都跑通。
证据见 docs/evidence-*.txt。
2026-09-14 00:11:42 +08:00

123 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""vfspublish:把 .svfs 发布成"一堆按 hash 命名的对象 + 一份清单"。
产出可以直接 `aws s3 sync` / `rclone copy` 到对象存储或静态托管:
对象名就是内容的 sha256,所以客户端的缓存策略可以是"永久",
而且每个对象都能端到端校验(CDN 换不掉内容)。
"""
import argparse
import os
import sys
from .. import publish
from ..core import VFSFormatError
def publish_objects():
parser = argparse.ArgumentParser(
description="把虚拟文件系统导出成内容寻址的对象目录 + 清单"
)
parser.add_argument("db_file", help="SQLite数据库文件路径")
parser.add_argument("out_dir", help="输出目录(会创建 blobs/ 与 manifest.json)")
parser.add_argument(
"--snapshot", metavar="ID", help="发布指定快照(默认取最新快照)"
)
parser.add_argument(
"--shard",
type=int,
default=publish.DEFAULT_SHARD,
help=f"blobs 的分片层数,0 表示不分片(默认 {publish.DEFAULT_SHARD})",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="输出目录非空时也继续(会覆盖同名对象与清单)",
)
parser.add_argument(
"--no-objects",
action="store_true",
help="只生成清单,不导出对象(用于只更新清单的场景)",
)
args = parser.parse_args()
if not os.path.exists(args.db_file):
print(f"错误: 数据库文件不存在: {args.db_file}")
sys.exit(1)
done = [0]
def progress(current, total):
done[0] = current
if total and (current == total or current % 200 == 0):
print(f" 导出对象: {current}/{total}", flush=True)
try:
if args.no_objects:
from ..core import SQLiteVFS
vfs = SQLiteVFS(args.db_file, readonly=True, snapshot_id=args.snapshot)
try:
manifest = publish.build_manifest(
vfs, snapshot_id=args.snapshot, shard=args.shard
)
finally:
vfs.close()
os.makedirs(args.out_dir, exist_ok=True)
import json
path = os.path.join(args.out_dir, publish.MANIFEST_NAME)
payload = json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True)
with open(path, "w", encoding="utf-8") as f:
f.write(payload)
result = {
"out_dir": os.path.abspath(args.out_dir),
"manifest": path,
"manifest_bytes": len(payload.encode("utf-8")),
"objects": 0,
"objects_bytes": 0,
"files": manifest["stats"]["files"],
"directories": manifest["stats"]["directories"],
"dedup_saved": 0,
"snapshot_id": manifest["snapshot"]["id"],
"digest": manifest["snapshot"]["digest"],
}
else:
result = publish.export_objects(
args.db_file,
args.out_dir,
snapshot_id=args.snapshot,
shard=args.shard,
overwrite=args.overwrite,
progress=progress,
)
except VFSFormatError as e:
print(f"错误: {e}")
sys.exit(1)
except FileExistsError as e:
print(f"错误: {e}")
sys.exit(1)
except Exception as e:
print(f"错误: 发布失败: {e}")
sys.exit(1)
print("发布完成!")
print(f" 快照: {result['snapshot_id']} 摘要: {(result['digest'] or '')[:16]}")
print(f" 文件: {result['files']} 目录: {result['directories']}")
print(f" 对象: {result['objects']} 个,共 {result['objects_bytes'] / 1024:.1f} KB(去重后的原始字节)")
if result["dedup_saved"]:
print(f" 去重省下: {result['dedup_saved'] / 1024:.1f} KB(原始总大小 - 去重后)")
if result.get("compression_saved"):
print(
f" 库内实际占用: {result['stored_bytes'] / 1024:.1f} KB"
f"(压缩省下 {result['compression_saved'] / 1024:.1f} KB)"
)
print(f" 清单: {result['manifest']}({result['manifest_bytes'] / 1024:.1f} KB)")
print(f" 输出目录: {result['out_dir']}")
print(" 下一步: 把整个输出目录同步到对象存储/静态托管即可")
if __name__ == "__main__":
publish_objects()