Documentation
¶
Overview ¶
Package shadowdb manages session-scoped shadow tables for Agent multi-round reasoning. An Agent that reasons across several turns (build a wave, observe effects, revise, re-observe) needs a speculative write surface that does NOT touch production and is cleaned up when the session ends.
Design: column-tag isolation (scheme C). Callers provision a physical shadow table that mirrors the production schema plus a session_id VARCHAR(64) NOT NULL column. Opener.Open seeds the shadow for a given sessionID (INSERT INTO shadow SELECT session_id=?, * FROM seed). Every read and write inside the session carries AND session_id=? so two concurrent sessions never see each other's rows. Close deletes all rows tagged with the session's ID; a crash leaves orphans that the platform-layer Reap() cron cleans up.
Why column-tag over PG TEMP TABLE: pooled *sql.DB routes each query to any backend connection and PG TEMP tables are connection-scoped -- the temp table vanishes on the next query. PG TEMP would force *sql.Conn pinning, holding one backend connection per Agent session, and breaking driver portability (SQLite TEMP is connection-local, MySQL TEMP does not guarantee pool cleanup). Column-tag uses pure INSERT/UPDATE/DELETE/SELECT with ? params, one driver-agnostic code path, and matches the zero-external-dependency constitution.
Relationship to adjacent packages:
- staging (pkg/staging): decision-level pre-commit approval (one Record = one Agent decision). shadowdb is finer-grained (SQL-level scratchpad during reasoning) and feeds into staging: the final approved diff flows shadow -> staging -> production. shadowdb never merges to production directly.
- evolve.ShadowRunner (pkg/evolve): parameter-tuning shadow (compare fitness of baseline vs candidate parameter). Same word, different concept -- package named shadowdb to disambiguate.
- tools/builtin.SQLCASTool (pkg/tools/builtin): optimistic-lock UPDATE on staging/shadow tables. Orthogonal; SQLCASTool writes inside a shadow session just as it writes inside a staging table.
- tools/builtin.StagingDB: sibling intent-marker newtype. This package defines ShadowDB with the same shape; the two are conceptually siblings but kept in separate packages to avoid an import cycle through tools/builtin.
Pull-only API (product decision #5, consistent with staging): Opener.Reap is a function call the platform layer invokes from its own scheduler. core does NOT spawn goroutines or run watchdog loops.
Package shadowdb 管理 session 级影子表, 服务于 Agent 多轮推理. Agent 在多轮决策中 (建波次 -> 看效果 -> 改 -> 再看) 需要一个 试探性写入面: 不碰生产, session 结束时清理.
设计: 列标记隔离 (方案 C). 调用方预建一张物理影子表, schema 与生产对齐并额外有 session_id VARCHAR(64) NOT NULL 列. Opener.Open 按 sessionID 种子化 (INSERT INTO shadow SELECT session_id=?, * FROM seed). session 内每次读写都带 AND session_id=?, 两个并发 session 绝不会看到对方的行. Close 删掉带本 session ID 的所有 行; 崩溃留下的孤儿由平台层的 Reap() cron 清理.
为什么选列标记而非 PG TEMP TABLE: pool 化的 *sql.DB 会把每条 query 路由到任一后端连接, PG TEMP 是 connection-scoped 的 -- 下一条 query 上 temp 表就消失了. PG TEMP 会逼迫 *sql.Conn 绑定, 每个 Agent session 占一条后端连接, 并且破坏 driver 可移植性 (SQLite TEMP 是 connection-local, MySQL TEMP 不保证连接池回收). 列标记路径仅用 INSERT/UPDATE/DELETE/SELECT + ? 参数, 单一 driver 无关代码路径, 契合 "零外部依赖" 宪法.
与相邻包的关系:
- staging (pkg/staging): 决策级 pre-commit 审批 (一条 Record = 一次 Agent 决策). shadowdb 粒度更细 (推理期间的 SQL 级 scratchpad), 并作为 staging 上游: 最终通过的 diff 流转为 shadow -> staging -> 生产. shadowdb 绝不直接合并进生产.
- evolve.ShadowRunner (pkg/evolve): 参数级调优 shadow (比较 baseline 与候选参数 fitness). 同词不同概念 -- 本包命名为 shadowdb 消歧义.
- tools/builtin.SQLCASTool (pkg/tools/builtin): staging/shadow 表乐观锁 UPDATE. 正交; SQLCASTool 在 shadow session 内写, 与在 staging 表内写一样.
- tools/builtin.StagingDB: 同族意图标记 newtype. 本包定义 ShadowDB 同构, 两者概念兄弟, 刻意分包避免经 tools/builtin import cycle.
Pull-only API (产品决策 #5, 与 staging 一致): Opener.Reap 是 函数调用, 由平台层从自己的调度器触发. core 不起 goroutine, 不跑 watchdog 循环.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrSessionNotFound is returned by Close / Reap when the // sessionID is unknown to the Opener. Typically indicates a // double-close or a crash-recovery attempt on a reaped session. // // ErrSessionNotFound 在 Close / Reap 遇到 Opener 不认识的 // sessionID 时返回. 通常意味着重复 Close 或在已 Reap 的 // session 上做崩溃恢复. ErrSessionNotFound = errors.New("shadowdb: session not found") // ErrInvalidIdentifier is returned when Options.ShadowTable or // Options.SeedFromTable does not match the plain SQL identifier // pattern [a-zA-Z_]\w*. Quoted identifiers (backtick / double // quote) are deliberately rejected -- same policy as // tools/builtin.SQLCASTool for consistency across the SQL toolchain. // // ErrInvalidIdentifier 在 Options.ShadowTable 或 Options.SeedFromTable // 不匹配纯 SQL 标识符模式 [a-zA-Z_]\w* 时返回. 反引号 / 双引号 // 标识符刻意拒绝 -- 与 tools/builtin.SQLCASTool 策略一致, 保持 // SQL 工具链标识符契约统一. ErrInvalidIdentifier = errors.New("shadowdb: invalid SQL identifier") // ErrEmptySessionID is returned by Open on an empty sessionID. // Empty IDs would merge all anonymous sessions into one filter // bucket (session_id=”) and defeat isolation. // // ErrEmptySessionID 在 Open 接到空 sessionID 时返回. 空 ID 会把 // 所有匿名 session 合并到同一 filter 桶 (session_id=”), 使隔离 // 完全失效. ErrEmptySessionID = errors.New("shadowdb: sessionID must be non-empty") // ErrDuplicateSession is returned by Open when sessionID is // already tracked as an open session. Callers should Close the // prior session before re-opening the same ID, or use a fresh // ID. // // ErrDuplicateSession 在 Open 遇到已存在的 sessionID 时返回. // 调用方应先 Close 旧 session 再以同 ID 重开, 或直接换用新 ID. ErrDuplicateSession = errors.New("shadowdb: sessionID already open") )
Sentinel errors for Opener and Session failures. Implementations wrap these with %w so callers classify with errors.Is.
Opener 与 Session 的哨兵错误. 实现以 %w 包装, 调用方用 errors.Is 分类.
var ErrMissingSessionFilter = errors.New("shadowdb: SQL missing session_id=? filter")
ErrMissingSessionFilter is returned by EnforceSessionFilter when the provided SQL does not contain a session_id=? filter (outside of string literals and comments). Intended to signal a three-layer-defense middle-tier rejection: the tool description told the LLM to include the filter; EnforceSessionFilter confirms it did; the DB column session_id NOT NULL + audit is the last line.
ErrMissingSessionFilter 在提供的 SQL 不含 session_id=? filter (字符串字面与注释之外) 时返回. 表示 "三层防御" 中层拒绝: 工具 描述要求 LLM 带 filter; EnforceSessionFilter 复核; DB 的 session_id NOT NULL + 审计是最后防线.
Functions ¶
func EnforceSessionFilter ¶
EnforceSessionFilter returns nil if sql contains a session_id=? filter outside string literals and comments, ErrMissingSessionFilter otherwise. Case-insensitive on "session_id"; allows arbitrary whitespace around the "=".
Heuristic, not parser: strips single-quote strings, double-quote strings (SQL standard identifier quoting -- MySQL's ANSI_QUOTES mode), line comments (-- ...\n), and block comments (/* ... */), then scans the residue for the regex. Does NOT defend against adversarial constructions such as dynamic SQL assembly; the goal is to catch casual LLM forgetfulness, not to prove correctness. The DB column NOT NULL + audit is the last line.
Scope limitation: recognises only "session_id = ?". SQL that uses different forms (IN (?) / IS NULL / a column aliased to session_id) will be rejected even if semantically correct. Callers who need those forms bypass EnforceSessionFilter and build a platform-layer validator.
EnforceSessionFilter 在 sql 于字符串字面与注释之外包含 session_id=? filter 时返回 nil, 否则返回 ErrMissingSessionFilter. "session_id" 大小写不敏感; "=" 两侧允许任意空白.
启发式而非 parser: 剥离单引号字符串 / 双引号字符串 (SQL 标准 标识符引用 -- MySQL ANSI_QUOTES 模式) / 行注释 (-- ...\n) / 块注释 (/* ... */), 对残留正则匹配. 不防动态 SQL 拼接等对抗性 构造; 目标是捕捉 LLM 随手遗漏, 不证正确性. DB 列 NOT NULL + 审计是最后防线.
作用域限制: 只认 "session_id = ?". SQL 若用其他形态 (IN (?) / IS NULL / 列别名为 session_id) 即便语义正确也会被拒. 需要这些 形态的调用方绕过 EnforceSessionFilter, 在平台层做自己的 validator.
func ValidateIdentifier ¶
ValidateIdentifier returns nil if s is a plain SQL identifier ([a-zA-Z_]\w*), or ErrInvalidIdentifier otherwise. Quoted identifiers are rejected. Exported so callers (tests, platform adapters) can pre-validate before constructing Options.
ValidateIdentifier 在 s 为纯 SQL 标识符 ([a-zA-Z_]\w*) 时返回 nil, 否则返回 ErrInvalidIdentifier. 反引号 / 双引号标识符被拒. 导出让调用方 (测试 / 平台适配层) 构造 Options 前可预校验.
Types ¶
type InMemoryOpener ¶
type InMemoryOpener struct {
// contains filtered or unexported fields
}
InMemoryOpener is the reference Opener implementation. It holds a ShadowDB handle and an in-process session registry (map + sync.Mutex). Sessions are tracked by sessionID; Close / Reap consult the map, the shadow table rows are the physical state.
Scope: suitable for tests, dev harnesses, and single-process demos. Production deployments that need cross-process session visibility or crash recovery belong in platform-layer Opener implementations (e.g. a SQL-backed registry table). InMemoryOpener intentionally keeps the registry in-memory so the reference implementation has zero storage assumptions beyond the injected ShadowDB.
Thread safety: all Opener methods hold the mutex only for the registry bookkeeping window; SQL executions happen outside the lock so a slow DB does not stall concurrent Opens. The registry ensures at most one Open per sessionID.
InMemoryOpener 是 Opener 的参考实现. 持有 ShadowDB handle 与进程 内 session 注册表 (map + sync.Mutex). Session 按 sessionID 索引; Close / Reap 查 map, 影子表行是物理状态.
适用范围: 测试 / dev / 单进程 demo. 生产部署若需跨进程可见性或 崩溃恢复, 应在平台层做 Opener 实现 (例如 SQL 注册表). InMemoryOpener 刻意把注册表放内存, 参考实现对存储的假设仅限注入的 ShadowDB.
线程安全: Opener 方法仅在注册表簿记窗口持锁, SQL 执行在锁外, 避免慢 DB 阻塞并发 Open. 注册表保证同 sessionID 最多一次 Open.
func NewInMemoryOpener ¶
func NewInMemoryOpener(db ShadowDB) *InMemoryOpener
NewInMemoryOpener constructs the reference Opener. Panics on invalid DI (db.DB nil) so startup errors surface immediately rather than on first Open.
NewInMemoryOpener 构造参考 Opener. 非法 DI (db.DB 为 nil) panic, 启动期暴露而非首次 Open 才炸.
func (*InMemoryOpener) Len ¶
func (o *InMemoryOpener) Len() int
Len reports the number of currently-open sessions. Intended for test assertions and small-scale observability; platform-layer opener implementations may add richer List / Stats methods.
Len 报告当前打开的 session 数. 给测试断言与小规模观测用; 平台层 opener 实现可追加更丰富的 List / Stats 方法.
func (*InMemoryOpener) Open ¶
func (o *InMemoryOpener) Open(ctx context.Context, sessionID string, opts Options) (*Session, error)
Open implements Opener.Open.
Seeding SQL when opts.SeedFromTable is non-empty:
INSERT INTO {ShadowTable} SELECT ?, * FROM {SeedFromTable}
Contract: {ShadowTable} column order MUST be (session_id, ...same as SeedFromTable columns in order). Caller pre-provisions the shadow table that way; a schema mismatch surfaces as a driver error from this INSERT, which is returned verbatim (not wrapped in a shadowdb sentinel) so the caller sees the exact driver message.
Identifier validation happens before registry mutation so a rejected Options leaves the registry untouched.
Open 实现 Opener.Open.
opts.SeedFromTable 非空时的种子 SQL:
INSERT INTO {ShadowTable} SELECT ?, * FROM {SeedFromTable}
契约: {ShadowTable} 的列顺序必须是 (session_id, ...SeedFromTable 同序列). 调用方预建影子表时即按此排布; schema 不匹配以 driver 错误在本 INSERT 暴露, 原样返回 (不包 shadowdb 哨兵), 调用方可见 原始 driver 信息.
标识符校验在注册表变更前完成, Options 被拒时注册表状态不变.
func (*InMemoryOpener) Reap ¶
func (o *InMemoryOpener) Reap(ctx context.Context, olderThan time.Duration) (ReapResult, error)
Reap implements Opener.Reap. Walks the registry, collects sessions whose createdAt is at or before now-olderThan, and closes them one by one. Per-session errors accumulate in ReapResult.Errors; Reap returns a top-level error only when the context is cancelled mid-sweep (so callers see a clean abort signal). ErrSessionNotFound from the close closure is benign (Close raced with Reap) and is not recorded.
Reap 实现 Opener.Reap. 遍历注册表, 收集 createdAt 早于或等于 now-olderThan 的 session 并逐个关闭. per-session 错误累积到 ReapResult.Errors; Reap 仅在 context 取消时返回顶层错误 (干净 中断信号). close 闭包的 ErrSessionNotFound 是良性 (Close 与 Reap 赛跑), 不记录.
type Opener ¶
type Opener interface {
// Open creates a new shadow session tagged with sessionID. The
// physical shadow table (opts.ShadowTable) MUST exist with a
// session_id VARCHAR(64) NOT NULL column and a schema that
// mirrors opts.SeedFromTable (if seeding is requested).
//
// Seeding: if opts.SeedFromTable is non-empty, Open executes
// INSERT INTO shadow SELECT sessionID AS session_id, * FROM seed.
// If empty, the session starts with an empty shadow (caller
// writes from scratch).
//
// Returns ErrEmptySessionID if sessionID is "", ErrDuplicateSession
// if sessionID is already open on this Opener, or
// ErrInvalidIdentifier if ShadowTable / SeedFromTable fail
// ValidateIdentifier. Any driver-level error is returned
// verbatim, not wrapped in a shadowdb sentinel.
//
// Open 创建新的 shadow session 并以 sessionID 打标. 物理影子
// 表 (opts.ShadowTable) 必须预建, 带 session_id VARCHAR(64)
// NOT NULL 列, 且 schema 与 opts.SeedFromTable 对齐 (如要种子).
//
// 种子: opts.SeedFromTable 非空时, Open 执行 INSERT INTO shadow
// SELECT sessionID AS session_id, * FROM seed. 为空则 session
// 从空 shadow 开始 (调用方自己写).
//
// sessionID 为空返回 ErrEmptySessionID, 已存在返回
// ErrDuplicateSession, ShadowTable / SeedFromTable 校验失败
// 返回 ErrInvalidIdentifier. driver 级错误原样返回, 不包 shadowdb
// 哨兵.
Open(ctx context.Context, sessionID string, opts Options) (*Session, error)
// Reap finds sessions whose CreatedAt is older than now-olderThan
// and closes them (DELETE WHERE session_id=? for each). Intended
// for platform-layer cron to clean up crashed / abandoned
// sessions that missed Close. A zero olderThan reaps everything
// currently open; callers almost always want a positive duration.
//
// Returns ReapResult with per-session outcomes. Per-session
// errors accumulate in Errors rather than aborting the sweep --
// the next session's cleanup is independent.
//
// Reap 找出 CreatedAt 比 now-olderThan 更早的 session 并关闭
// (对每个 DELETE WHERE session_id=?). 给平台层 cron 清理错过
// Close 的崩溃 / 弃置 session. olderThan 为 0 会回收所有当前
// 打开的 session, 调用方几乎总应传正值.
//
// 返回 ReapResult 汇总 per-session 结果. per-session 错误累积到
// Errors 而不中止扫描 -- 下一个 session 的清理独立进行.
Reap(ctx context.Context, olderThan time.Duration) (ReapResult, error)
}
Opener opens, closes, and reaps session-scoped shadow tables. Implementations:
- InMemoryOpener (this package): reference implementation backed by a ShadowDB handle. Suitable for tests and small in-process deployments.
- Future SQL-backed openers belong in platform-layer packages that hold a real driver (platform/internal/shadowdb/...). core ships only the interface and the reference impl.
Thread safety: all methods MUST be safe for concurrent use from multiple goroutines. A single Opener instance is expected to serve many Agent sessions.
Lifecycle ownership: core does NOT spawn goroutines. Reap is a blocking function call; the platform layer invokes it from its own cron / watchdog / worker loop. This keeps core free of runtime-infrastructure responsibilities and lets the platform layer choose scheduling granularity (per-minute cron, event-driven after Agent session ends, on-demand from admin HTTP).
Opener 负责打开 / 关闭 / 回收 session 级影子表. 实现:
- InMemoryOpener (本包): 基于 ShadowDB handle 的参考实现. 适合测试与小型进程内部署.
- 未来 SQL-backed opener 属于持有真实 driver 的平台层包 (platform/internal/shadowdb/...). core 只发接口和参考实现.
线程安全: 所有方法必须支持多 goroutine 并发. 单个 Opener 实例 预期服务多个 Agent session.
生命周期归属: core 不起 goroutine. Reap 是阻塞的函数调用, 平台 层从自己的 cron / watchdog / worker 循环触发. 这让 core 免于 运行时基础设施责任, 也让平台层自选调度粒度 (分钟级 cron / Agent session 结束事件驱动 / admin HTTP 按需触发).
type Options ¶
type Options struct {
// ShadowTable is the physical table name that carries shadow
// rows for this session. Required. Must pass ValidateIdentifier
// (plain [a-zA-Z_]\w*, no quoting). The table MUST exist with
// a session_id VARCHAR(64) NOT NULL column; core does not
// CREATE TABLE for the caller (schema-agnostic contract).
//
// ShadowTable 是承载本 session 影子行的物理表名. 必填, 必须通过
// ValidateIdentifier (纯 [a-zA-Z_]\w*, 无引号). 表必须预建, 带
// session_id VARCHAR(64) NOT NULL 列; core 不替调用方 CREATE TABLE
// (schema-agnostic 契约).
ShadowTable string
// SeedFromTable is the optional source table name for initial
// population. If non-empty, Open issues INSERT INTO shadow
// SELECT sessionID AS session_id, * FROM seed. If empty, the
// session starts empty and the caller populates via subsequent
// INSERTs. Must pass ValidateIdentifier when non-empty.
//
// The schema of SeedFromTable MUST match ShadowTable minus the
// session_id column; otherwise the INSERT ... SELECT fails at
// Open time with a driver error, surfacing the mismatch early.
//
// SeedFromTable 是可选的种子源表名. 非空时 Open 执行 INSERT
// INTO shadow SELECT sessionID AS session_id, * FROM seed.
// 为空则 session 从空开始, 调用方经后续 INSERT 自行填充. 非空
// 时必须通过 ValidateIdentifier.
//
// SeedFromTable 的 schema 必须与 ShadowTable 扣去 session_id
// 列后对齐, 否则 INSERT ... SELECT 在 Open 阶段即以 driver
// 错误失败, 早暴露 schema 不匹配.
SeedFromTable string
// OnClose is invoked with the underlying error if Close fails
// (typically a driver-level error during DELETE). Intended
// hook-up point for platform-layer alerting / VerdictSink /
// orphan-count metrics. nil disables the callback.
//
// Not called on successful Close or on Reap-initiated cleanup
// (Reap collects its own errors in ReapResult). Keeps the
// callback semantics "only fire on anomalous single-session
// close" so alerting rules stay crisp.
//
// OnClose 在 Close 失败时被调用, 参数为底层错误 (通常 DELETE 的
// driver 级错误). 给平台层告警 / VerdictSink / 孤儿数指标提供
// 钩子. nil 禁用回调.
//
// 成功 Close 不触发, Reap 主导的清理也不触发 (Reap 在 ReapResult
// 自收错误). 保持回调语义为 "仅单 session 异常 Close 时触发",
// 告警规则干净.
OnClose func(error)
}
Options configures Session construction at Open time. All fields are validated synchronously; misconfiguration surfaces as an error from Open, not as a deferred failure on first Exec.
Options 在 Open 时配置 Session 构造. 所有字段同步校验, 配置错误 经 Open 即时暴露, 不推迟到首次 Exec 炸出.
type ReapResult ¶
type ReapResult struct {
// Swept is the number of sessions this Reap call closed
// successfully (Close returned nil).
//
// Swept 是本次 Reap 成功关闭的 session 数 (Close 返回 nil).
Swept int
// Errors holds the per-session errors encountered during the
// sweep. Empty slice means clean sweep. Errors are wrapped with
// the sessionID in %w context so callers can identify which
// session failed without the Opener re-emitting logs.
//
// Errors 承载扫描中遇到的 per-session 错误. 空 slice 表示干净
// 扫描. 错误以 %w 附带 sessionID 上下文包装, 调用方可据此识别
// 哪个 session 失败, Opener 不必再打日志.
Errors []error
}
ReapResult aggregates Reap outcomes across swept sessions. Callers typically log (Swept, Errors) for observability and alert when len(Errors) > 0 or Swept > some threshold.
ReapResult 汇总 Reap 扫过所有 session 的结果. 调用方通常以 (Swept, Errors) 打日志做观测, len(Errors) > 0 或 Swept 超阈值 时告警.
type Session ¶
type Session struct {
ID string
CreatedAt time.Time
ShadowTable string
DB ShadowDB
// contains filtered or unexported fields
}
Session is one session-scoped shadow-table handle. Opener.Open returns an opaque *Session; callers invoke Close to delete the session's tagged rows. Exec/Query go through the DB field, but the session_id filter must be appended to every WHERE (see ShadowExecTool decorator for automatic injection).
Field semantics:
- ID is the session_id value used in the physical shadow table. Treated as opaque by shadowdb; callers pass it through their SQL as the session_id filter value.
- CreatedAt is set by Opener.Open to time.Now().UTC() at open time. Reap compares against this field to identify orphans that missed Close.
- ShadowTable is the physical table name carried on the Session so a decorator or tool can retrieve it without threading Options through the call chain.
- DB is the ShadowDB handle bound to this session. Callers issue SQL through DB.QueryContext / DB.ExecContext; none of that goes through Session methods.
Session is a value-identity type: two *Session with the same ID and owner Opener are functionally interchangeable. Close is idempotent at the Opener level (second call returns ErrSessionNotFound, a distinct error so callers can separate "already closed" from wire errors).
Session 是一次 session 级影子表 handle. Opener.Open 返回不透明 的 *Session; 调用方调 Close 以删除 session 标记的行. Exec/Query 经 DB 字段发出, 但每个 WHERE 必须追加 session_id filter (自动 注入见 ShadowExecTool 装饰器).
字段语义:
- ID 是物理影子表中的 session_id 值. shadowdb 视作不透明, 调用方 在 SQL 中按 session_id filter 值传入.
- CreatedAt 由 Opener.Open 在开启时设为 time.Now().UTC(). Reap 比较此字段判定错过 Close 的孤儿.
- ShadowTable 把物理表名挂在 Session 上, 装饰器 / 工具不用再把 Options 层层透传也能取到.
- DB 是绑定到本 session 的 ShadowDB handle. 调用方经 DB.QueryContext / DB.ExecContext 发 SQL, 不走 Session 方法.
Session 为值语义类型: 同 ID 同 Opener 的两个 *Session 功能等价. Close 在 Opener 层幂等 (二次调用返回 ErrSessionNotFound, 独立错误 使调用方区分 "已 Close" 与接线错).
func (*Session) Close ¶
Close deletes all rows tagged with this session's ID from the physical shadow table and detaches the Session from its Opener. After Close returns nil, subsequent Close calls on the same Session return ErrSessionNotFound from the Opener.
Close semantics: MUST be safe to defer. A nil *Session (not returned by any Open success path, but defensively handled) returns nil so defer does not panic on a failed-Open path.
Close 删除物理影子表中本 session ID 标记的所有行, 并把 Session 从 Opener 脱钩. Close 返回 nil 后, 对同一 Session 的后续 Close 经 Opener 返回 ErrSessionNotFound.
Close 语义: 必须可 defer. nil *Session (任何成功 Open 路径都不 返回, 但为防御 Open 失败路径保留此) 返回 nil, 以免 defer 在失败 路径 panic.
type ShadowDB ¶
ShadowDB wraps *sql.DB at the API boundary to signal that the handle targets a physical shadow table rather than production OLTP. Analogous to StagingDB in tools/builtin/sql_cas.go -- an intent marker, not a sandbox. It does not defend against ShadowDB{prodDB} lies, but turns the shadow-scope claim from a godoc note into a type signature every call site must write explicitly.
Embedding *sql.DB means method promotion keeps the ergonomic API (shadowdb.ShadowDB{db}.QueryRow works without manual unwrap).
ShadowDB 在 API 边界包装 *sql.DB, 表明该 handle 指向物理影子表 而非生产 OLTP. 与 tools/builtin/sql_cas.go 的 StagingDB 同族 -- 意图标记而非沙盒. 无法防 ShadowDB{prodDB} 撒谎, 但把 "shadow 作用域" 从 godoc 注释升格为类型签名, 每个调用点必须显式写出.
内嵌 *sql.DB 经 method promotion 保留人体工学 (shadowdb.ShadowDB{db}.QueryRow 可直接用, 无需手工 unwrap).