Files
pad/internal/store/storetest/writecapture.go
T
xarmian 85c49cf2fd feat(appstore): FencedTx, the appstore skeleton, and the boundary and write-capture guards (TASK-3388) (#1764)
* feat(appstore): FencedTx, the appstore skeleton, and the boundary and write-capture guards (TASK-3388)

SPEC-6 U1 (DOC-3371 v12 §4): the door every app write will go through, and
the two tests that guard it. No app mutations or routes yet; U2 adds them
under these guards.

- store.FencedTx:
  - BeginFenced's first statement reads app_installs (auth_epoch, state,
    workspace), FOR SHARE on Postgres, and refuses unless the admitted epoch
    is current, the install is active and the workspace matches.
  - There is no raw Exec and no free-form query. The lock primitives
    (LockWorkspaceSeq, NextItemNumber, NextSeq) take the human item path's
    own workspace lock.
  - Companion guards: requireCompanionCollection, and requireCompanionItem
    for derived rows.
  - Every field is unexported, and a zero value refuses every method.
- internal/appstore: holds *store.Store concretely; withFence runs one
  mutation in one FencedTx; CheckFence is the live path until U2.
- The boundary test (go/packages + go/types) walks every module function
  the package can reach. It fails on:
  - a *store.Store method off a reviewed allow-list (stronger than the
    spec's deny-list)
  - database/sql calls outside the door
  - Store.db or (*Store).DB
  - an interface call or declaration an internal/store type answers
  - reflect or unsafe
  - the ordinary attachments Put

  One fixture per rule under testdata is asserted to be reported.
- The reverse test: no package outside internal/store and internal/appstore
  references BeginFenced.
- storetest.CaptureWrites:
  - SQLite: modernc's pre-update hook on a single connection, with a
    sentinel write proving the hook is still on it.
  - Postgres: statement-level audit triggers installed by the harness.

  The harness is proven red on a human create (items plus FTS5 shadow
  tables) and on a test-only trigger write at depth > 0, and silent on a
  read. The blind inventory is asserted exactly: items_fts, comments_fts,
  documents_fts and sqlite_sequence.
- guardConn.Unwrap exposes the driver connection to the harness.
- Migration: app_installs (SQLite 112 / PG 086); its columns are added to
  the NUL baseline, since no writer exists before U8, which must validate
  origin.

Moved to U2, where they are first used: DebounceActivity, CheckItemCap and
the via_app columns.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(appstore): close the boundary checker's gaps and the harness's coverage holes (TASK-3388)

Codex round 1. Each gap now has a testdata fixture asserted to be reported,
and the six P1 fixtures were checked against the round-0 checker:
- package-var: package-level var initializers and init functions are walked
  for the root and every module package it brings in that the human store's
  own closure does not
- generic: instantiated generic methods are normalized through Origin()
- embedded-sql: rules check a method's DECLARING receiver, so a method
  promoted through an embedded *sql.DB or *store.Store is caught at the call
- func-field: a call through a function value is refused (dynamic-call)
  unless it is a parameter of an unexported root function that every call
  site passes a literal or a named function
- linkname: a go:linkname directive or an unsafe import in a walked package
  is refused
- whole module: the walk covers the whole module, not only internal/
- embedded-store: the old checker caught this only by walking into
  DeleteItem's body; it is now caught at the call

Against the round-0 checker, all six P1 fixtures fail (pkgvar, helperinit,
generic, embeddedsql, funcfield, linkname); the extra embeddedstore fixture
passed there, for the reason above.

Harness:
- SQLite: PRAGMA data_version, read on the hooked connection at the start
  and end, refuses a capture during which another connection committed. A
  replaced connection is refused at the sentinel. Both branches are tested;
  the data_version test passes silently without the check.
- Postgres: triggers are ensured at every capture, so tables created later
  are covered; a table created during a capture is refused; captures are
  serialized per database.
- The Postgres lock tests wait until pg_stat_activity shows a backend
  waiting on a Lock, instead of treating 500ms as proof of blocking.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(appstore): refuse every function value instead of verifying callbacks; working-trigger check (TASK-3388)

Codex round 2 found five P1s in the checker and one P2 in the harness.

Four of the P1s were bypasses of one mechanism: verifying where a callback
parameter came from. They were reassignment, aliasing through a pointer, a
method expression shifting the argument index, and interface dispatch past
the call-site census. This is a guard, not a proof, so the mechanism is
removed rather than patched again:
- withFence(fn) is gone; app mutations open and commit their FencedTx
  directly.
- Every call through a function value is refused (dynamic-call).
- Any use of a variable, parameter or field of function type is refused
  (func-value). This also closes the fifth P1: an exported function-typed
  package var in the store's own import closure, run through
  sync.Once.Do, whose initializer the walk excludes.

Interface checks now judge the INSTANTIATED receiver, since Mutator[string]
can be implemented where Mutator[T] is not; Origin() is used only for
indexing.

Harness P2: a pad_write_audit_t trigger counts only when it is enabled,
statement-level, AFTER, on INSERT/UPDATE/DELETE, and calls
pad_write_audit_fn. Otherwise it is dropped and recreated.

Fixtures genericiface, callbackparam, methodexprcallback and oncevar each
pass against the round-1 checker and are caught now. A Postgres test
disables the trigger and expects the next capture to see the write.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(appstore): refuse interface dispatch on unbound type arguments (TASK-3388)

Codex round 3: a generic helper run[T](m Mutator[T]) is walked once, with T
unbound, so Mutator[T] never matched *store.Store and the call went
unflagged. An interface call whose receiver is, or is instantiated with, a
type parameter is now refused, rather than propagating type arguments
through the walk. Fixture genericifacebody passes the round-2 checker and is
caught now.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(appstore): no interface dispatch inside a generic body (TASK-3388)

Codex round 4 found two more ways a type parameter hides from the
round-3 check:
- an anonymous interface constraint (or a generic alias)
- a type parameter nested inside a func, slice, map or alias type argument

Chasing type parameters through every type form is open-ended, so the class
is closed outright: a generic function body, walked once with its parameters
unbound, may make no interface method call except on error. A non-generic
function cannot hold an unbound type parameter, so the ordinary
implemented-by-store check stays exact there. Fixtures genericconstraint and
genericnested both pass the round-3 checker and are caught now.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-03 16:02:18 -04:00

273 lines
9.2 KiB
Go

package storetest
import (
"context"
"database/sql/driver"
"fmt"
"sort"
"sync"
"testing"
"modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
"github.com/PerpetualSoftware/pad/internal/store"
)
// Write is one write the database recorded while a capture was running.
type Write struct {
Table string
Op string // INSERT, UPDATE or DELETE
// Depth is SQLite's sqlite3_preupdate_depth: 0 for a statement the
// caller ran, above 0 for a write a trigger made. Postgres reports 0.
Depth int
}
// CaptureWrites runs fn and returns every table write it caused, as the
// DATABASE saw it, triggers included (SPEC-6 §4, TASK-3388). It is the
// app store's write census instrument: a GUARD, not a proof.
//
// SQLite: the store is pinned to ONE connection (MaxOpenConns(1), no
// lifetime or idle expiry), and modernc's pre-update hook is registered on
// it, so every statement fn runs is observed. The hook does NOT fire for
// virtual tables or SQLite's own system tables. That blind inventory is
// asserted exactly by TestBlindInventory; FTS5's shadow tables are ordinary
// tables and ARE observed. A sentinel write into a temp table before and
// after fn proves the hook was still on the connection fn used. The store is
// left on one connection afterwards: a test that captures must not then
// expect concurrent store work.
//
// Postgres: a statement-level AFTER trigger on every table (installed by
// this harness, never by a shipped migration) appends to pad_write_audit,
// and the rows fn added are returned. A statement that changes zero rows
// still fires, so Postgres over-reports rather than under-reports.
func CaptureWrites(t testing.TB, s *store.Store, fn func()) []Write {
t.Helper()
if s.D().Driver() == store.DriverPostgres {
return capturePostgres(t, s, fn)
}
return captureSQLite(t, s, fn)
}
// Tables returns the distinct tables in ws, sorted.
func Tables(ws []Write) []string {
seen := map[string]bool{}
var out []string
for _, w := range ws {
if !seen[w.Table] {
seen[w.Table] = true
out = append(out, w.Table)
}
}
sort.Strings(out)
return out
}
const sentinelTable = "pad_capture_sentinel"
func captureSQLite(t testing.TB, s *store.Store, fn func()) []Write {
t.Helper()
ctx := context.Background()
db := s.DB()
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
db.SetConnMaxIdleTime(0)
var mu sync.Mutex
var writes []Write
hook := func(d sqlite.SQLitePreUpdateData) {
op := "?"
switch d.Op {
case sqlite3.SQLITE_INSERT:
op = "INSERT"
case sqlite3.SQLITE_UPDATE:
op = "UPDATE"
case sqlite3.SQLITE_DELETE:
op = "DELETE"
}
mu.Lock()
writes = append(writes, Write{Table: d.TableName, Op: op, Depth: d.Depth()})
mu.Unlock()
}
setHook := func(h sqlite.PreUpdateHookFn) {
conn, err := db.Conn(ctx)
if err != nil {
t.Fatalf("capture: conn: %v", err)
}
defer conn.Close()
if err := conn.Raw(func(dc any) error {
// The store wraps the driver connection in its NUL guard.
if u, ok := dc.(interface{ Unwrap() driver.Conn }); ok {
dc = u.Unwrap()
}
r, ok := dc.(sqlite.HookRegisterer)
if !ok {
return fmt.Errorf("driver connection %T has no pre-update hook", dc)
}
r.RegisterPreUpdateHook(h)
return nil
}); err != nil {
t.Fatalf("capture: %v", err)
}
}
sentinel := func(when string) {
mu.Lock()
before := len(writes)
mu.Unlock()
if _, err := db.Exec(`INSERT INTO temp.`+sentinelTable+` (at) VALUES (?)`, when); err != nil {
// The temp table exists only on the hooked connection.
t.Fatalf("capture: the %s sentinel failed (%v): the store is no longer on the hooked connection", when, err)
}
mu.Lock()
defer mu.Unlock()
if len(writes) != before+1 || writes[len(writes)-1].Table != sentinelTable {
t.Fatalf("capture: the %s sentinel was not observed; the hook is not on the connection the store used", when)
}
writes = writes[:before]
}
// PRAGMA data_version, read on the hooked connection, changes when ANY
// OTHER connection commits to the database. Equal values at start and end
// mean every write fn committed went through the hooked connection, even
// if something widened the pool in between.
dataVersion := func() int64 {
var v int64
if err := db.QueryRow(`PRAGMA data_version`).Scan(&v); err != nil {
t.Fatalf("capture: data_version: %v", err)
}
return v
}
if _, err := db.Exec(`CREATE TEMP TABLE IF NOT EXISTS ` + sentinelTable + ` (at TEXT)`); err != nil {
t.Fatalf("capture: sentinel table: %v", err)
}
setHook(hook)
defer setHook(nil)
sentinel("start")
dvStart := dataVersion()
fn()
// Pin the pool back to one connection before reading, so the read and
// the end sentinel land on the hooked connection if it still exists.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
sentinel("end")
if dvEnd := dataVersion(); dvEnd != dvStart {
t.Fatalf("capture: another connection committed during capture (data_version %d -> %d); its writes were not observed", dvStart, dvEnd)
}
mu.Lock()
defer mu.Unlock()
return append([]Write(nil), writes...)
}
// pgCaptureLocks serializes captures per database: the audit window is a
// range of audit ids, so two captures running at once would read each other's
// writes, and one installing triggers could race the other's callback.
var pgCaptureLocks sync.Map // database name -> *sync.Mutex
func capturePostgres(t testing.TB, s *store.Store, fn func()) []Write {
t.Helper()
db := s.DB()
var dbName string
if err := db.QueryRow(`SELECT current_database()`).Scan(&dbName); err != nil {
t.Fatalf("capture: %v", err)
}
mu, _ := pgCaptureLocks.LoadOrStore(dbName, &sync.Mutex{})
mu.(*sync.Mutex).Lock()
defer mu.(*sync.Mutex).Unlock()
// Every capture re-checks coverage: a table created since the last one
// gets its trigger now.
installPGAudit(t, s)
var start int64
if err := db.QueryRow(`SELECT COALESCE(MAX(id), 0) FROM pad_write_audit`).Scan(&start); err != nil {
t.Fatalf("capture: audit start: %v", err)
}
fn()
rows, err := db.Query(`SELECT tbl, op FROM pad_write_audit WHERE id > $1 ORDER BY id`, start)
if err != nil {
t.Fatalf("capture: audit read: %v", err)
}
defer rows.Close()
var writes []Write
for rows.Next() {
var w Write
if err := rows.Scan(&w.Table, &w.Op); err != nil {
t.Fatalf("capture: audit scan: %v", err)
}
writes = append(writes, w)
}
if err := rows.Err(); err != nil {
t.Fatalf("capture: audit rows: %v", err)
}
// A table created DURING fn has no trigger, so its writes were not seen.
if missing := unauditedTables(t, s); len(missing) > 0 {
t.Fatalf("capture: tables created during capture are unaudited: %v", missing)
}
return writes
}
// unauditedTables lists base tables in the store's schema with no WORKING
// audit trigger. A same-named trigger only counts when it is enabled,
// statement-level, AFTER, fires on INSERT, UPDATE and DELETE, and calls
// pad_write_audit_fn (tgtype bits: ROW 1, BEFORE 2, INSERT 4, DELETE 8,
// UPDATE 16, INSTEAD 64). A disabled or repointed one is not coverage.
func unauditedTables(t testing.TB, s *store.Store) []string {
t.Helper()
rows, err := s.DB().Query(`SELECT c.relname FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema() AND c.relkind IN ('r', 'p') AND c.relname <> 'pad_write_audit'
AND NOT EXISTS (SELECT 1 FROM pg_trigger tg
WHERE tg.tgrelid = c.oid AND tg.tgname = 'pad_write_audit_t'
AND tg.tgenabled IN ('O', 'A')
AND (tg.tgtype & (1 | 2 | 64)) = 0
AND (tg.tgtype & (4 | 8 | 16)) = (4 | 8 | 16)
AND tg.tgfoid = (SELECT p.oid FROM pg_proc p JOIN pg_namespace pn ON pn.oid = p.pronamespace
WHERE p.proname = 'pad_write_audit_fn' AND pn.nspname = current_schema()))`)
if err != nil {
t.Fatalf("capture: list unaudited: %v", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("capture: list unaudited: %v", err)
}
out = append(out, name)
}
return out
}
// installPGAudit puts a statement-level AFTER trigger on every base table in
// the store's schema that does not have one yet. It runs at the start of
// every capture, so a table created after an earlier capture is covered.
func installPGAudit(t testing.TB, s *store.Store) {
t.Helper()
db := s.DB()
for _, stmt := range []string{
`CREATE TABLE IF NOT EXISTS pad_write_audit (id BIGSERIAL PRIMARY KEY, txid BIGINT NOT NULL, tbl TEXT NOT NULL, op TEXT NOT NULL)`,
`CREATE OR REPLACE FUNCTION pad_write_audit_fn() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO pad_write_audit (txid, tbl, op) VALUES (txid_current(), TG_TABLE_NAME, TG_OP);
RETURN NULL;
END $$`,
} {
if _, err := db.Exec(stmt); err != nil {
t.Fatalf("capture: install audit: %v", err)
}
}
// A table without a working trigger gets one, replacing any same-named
// trigger that is disabled or calls something else.
for _, tbl := range unauditedTables(t, s) {
for _, q := range []string{
fmt.Sprintf(`DROP TRIGGER IF EXISTS pad_write_audit_t ON %q`, tbl),
fmt.Sprintf(`CREATE TRIGGER pad_write_audit_t AFTER INSERT OR UPDATE OR DELETE ON %q FOR EACH STATEMENT EXECUTE FUNCTION pad_write_audit_fn()`, tbl),
} {
if _, err := db.Exec(q); err != nil {
t.Fatalf("capture: trigger on %s: %v", tbl, err)
}
}
}
}