package shadowdb import ( "context" "database/sql" "regexp" "time" ) // 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). type ShadowDB struct { *sql.DB } // 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" 与接线错). type Session struct { ID string CreatedAt time.Time ShadowTable string DB ShadowDB // close is the per-Opener cleanup closure captured at Open time. // Exposed only through the Close method so that Opener // implementations control how a session is dismantled (transaction // / straight DELETE / bulk truncation). // // close 是 Open 时 Opener 捕获的 per-session 清理闭包. 只通过 // Close 方法暴露, 让 Opener 实现决定拆除方式 (事务 / 直接 DELETE // / bulk truncate). close func(context.Context) error } // 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. func (s *Session) Close(ctx context.Context) error { if s == nil || s.close == nil { return nil } return s.close(ctx) } // reIdentifier matches the plain SQL identifier pattern accepted // by ValidateIdentifier. Kept in sync with // tools/builtin/sql_cas.go reIdentifier -- if one changes, the // other should too. (Deliberately duplicated rather than imported // to keep shadowdb free of a tools/builtin dependency.) // // reIdentifier 匹配 ValidateIdentifier 接受的纯 SQL 标识符模式. // 与 tools/builtin/sql_cas.go 的 reIdentifier 保持同步 -- 一处改 // 另一处也要改. (刻意复制而非 import, 以保 shadowdb 不依赖 // tools/builtin.) var reIdentifier = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) // 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 前可预校验. func ValidateIdentifier(s string) error { if !reIdentifier.MatchString(s) { return ErrInvalidIdentifier } return nil }