diff --git a/internal/graph/context_roundtrip_test.go b/internal/graph/context_roundtrip_test.go new file mode 100644 index 00000000..0efffe40 --- /dev/null +++ b/internal/graph/context_roundtrip_test.go @@ -0,0 +1,66 @@ +package graph + +import ( + "database/sql" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + _ "modernc.org/sqlite" +) + +// TestNodesTableReader_GetNode_CarriesContext pins bead mache-b8fe72: the +// mount reads nodes via NodesTableReader.GetNode, but it never selected the +// `context` column, so node.Context was always nil and the headline +// `cat context` virtual file (served by vfs.ContextHandler when +// len(node.Context) > 0) was absent for every construct. node.Context is +// populated at ingest (engine_walk.go) and survives in MemoryStore; it was +// only lost on the SQLite persist/read path. +func TestNodesTableReader_GetNode_CarriesContext(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "ctx.db") + db, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.Exec(`CREATE TABLE nodes ( + id TEXT PRIMARY KEY, parent_id TEXT, name TEXT, kind INTEGER, + size INTEGER, mtime INTEGER, record_id TEXT, record JSON, + source_file TEXT, context BLOB)`) + require.NoError(t, err) + + ctx := []byte("import (\n\t\"fmt\"\n)\n") + _, err = db.Exec(`INSERT INTO nodes (id, name, kind, size, mtime, context) VALUES (?, ?, ?, ?, ?, ?)`, + "pkg/methods/Foo.Bar", "Foo.Bar", NodeKindDir, int64(0), int64(0), ctx) + require.NoError(t, err) + + r := NewNodesTableReader(db, "results", stubRender, nil, 0o444, 0o555, 16) + got, err := r.GetNode("pkg/methods/Foo.Bar") + require.NoError(t, err) + assert.Equal(t, ctx, got.Context, + "mount path (NodesTableReader.GetNode) must carry node.Context — mache-b8fe72") +} + +// TestNodesTableReader_GetNode_MissingContextColumn is the backward-compat +// guard: older mache builds and leyline-produced nodes tables predate the +// `context` column. GetNode must degrade to an empty Context, never error. +func TestNodesTableReader_GetNode_MissingContextColumn(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "old.db") + db, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.Exec(`CREATE TABLE nodes ( + id TEXT PRIMARY KEY, parent_id TEXT, name TEXT, kind INTEGER, + size INTEGER, mtime INTEGER, record_id TEXT, record JSON, + source_file TEXT)`) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO nodes (id, name, kind, size, mtime) VALUES (?, ?, ?, ?, ?)`, + "pkg/methods/Foo.Bar", "Foo.Bar", NodeKindDir, int64(0), int64(0)) + require.NoError(t, err) + + r := NewNodesTableReader(db, "results", stubRender, nil, 0o444, 0o555, 16) + got, err := r.GetNode("pkg/methods/Foo.Bar") + require.NoError(t, err, "GetNode on a pre-context-column nodes table must not error") + assert.Empty(t, got.Context) +} diff --git a/internal/graph/nodes_table_reader.go b/internal/graph/nodes_table_reader.go index e07999ac..dfa2e7af 100644 --- a/internal/graph/nodes_table_reader.go +++ b/internal/graph/nodes_table_reader.go @@ -37,14 +37,28 @@ const ( // The caller owns the *sql.DB lifecycle — NodesTableReader holds a // reference but does not close it. type NodesTableReader struct { - db *sql.DB - tableName string // source records table ("results" or schema.Table) - render TemplateRenderer // for record_id fallback rendering - levels []*schemaLevel // compiled schema levels - fileMode os.FileMode // permission for file nodes - dirMode os.FileMode // permission for dir nodes - sizeCache sync.Map // file path → int64 - cache *ContentCache // FIFO-bounded rendered content + db *sql.DB + tableName string // source records table ("results" or schema.Table) + render TemplateRenderer // for record_id fallback rendering + levels []*schemaLevel // compiled schema levels + fileMode os.FileMode // permission for file nodes + dirMode os.FileMode // permission for dir nodes + sizeCache sync.Map // file path → int64 + cache *ContentCache // FIFO-bounded rendered content + hasContext bool // nodes table carries the context column (mache-b8fe72) +} + +// ColumnExists reports whether table has a column named col. Readers use it +// to stay compatible with nodes tables written before a column was added +// (e.g. `context`, mache-b8fe72) or produced by leyline, and writers use it +// to decide whether an ALTER is needed. +func ColumnExists(db *sql.DB, table, col string) bool { + rows, err := db.Query("SELECT 1 FROM pragma_table_info(?) WHERE name = ?", table, col) + if err != nil { + return false + } + defer func() { _ = rows.Close() }() + return rows.Next() } // DB returns the underlying database connection. @@ -56,13 +70,14 @@ func NewNodesTableReader(db *sql.DB, tableName string, render TemplateRenderer, levels []*schemaLevel, fileMode, dirMode os.FileMode, cacheSize int, ) *NodesTableReader { return &NodesTableReader{ - db: db, - tableName: tableName, - render: render, - levels: levels, - fileMode: fileMode, - dirMode: dirMode, - cache: NewContentCache(cacheSize), + db: db, + tableName: tableName, + render: render, + levels: levels, + fileMode: fileMode, + dirMode: dirMode, + cache: NewContentCache(cacheSize), + hasContext: ColumnExists(db, "nodes", "context"), } } @@ -76,8 +91,17 @@ func (r *NodesTableReader) GetNode(id string) (*Node, error) { var kind, size int var mtimeNano int64 var recordID sql.NullString - err := r.db.QueryRow("SELECT kind, size, mtime, record_id FROM nodes WHERE id = ?", id). - Scan(&kind, &size, &mtimeNano, &recordID) + var context []byte + // Older / leyline-produced nodes tables predate the context column; + // only select it when present so GetNode stays backward-compatible + // (mache-b8fe72). node.Context feeds the `context` virtual file. + query := "SELECT kind, size, mtime, record_id FROM nodes WHERE id = ?" + dest := []any{&kind, &size, &mtimeNano, &recordID} + if r.hasContext { + query = "SELECT kind, size, mtime, record_id, context FROM nodes WHERE id = ?" + dest = append(dest, &context) + } + err := r.db.QueryRow(query, id).Scan(dest...) if err == sql.ErrNoRows { return nil, ErrNotFound } @@ -94,6 +118,7 @@ func (r *NodesTableReader) GetNode(id string) (*Node, error) { ID: id, Mode: mode, ModTime: time.Unix(0, mtimeNano), + Context: context, } if kind == NodeKindFile { diff --git a/internal/ingest/sqlite_writer.go b/internal/ingest/sqlite_writer.go index 3efd6176..74d83fe2 100644 --- a/internal/ingest/sqlite_writer.go +++ b/internal/ingest/sqlite_writer.go @@ -61,7 +61,12 @@ func NewSQLiteWriter(dbPath string) (*SQLiteWriter, error) { mtime INTEGER NOT NULL, record_id TEXT, record JSON, - source_file TEXT + source_file TEXT, + -- context holds the imports/types visible to a construct scope, + -- served by the context virtual file (vfs.ContextHandler). Set at + -- ingest (engine_walk.go) and must survive the SQLite round-trip so + -- cat-context works on a mounted .db (mache-b8fe72). + context BLOB ); CREATE INDEX IF NOT EXISTS idx_parent_name ON nodes(parent_id, name); CREATE INDEX IF NOT EXISTS idx_source_file ON nodes(source_file); @@ -132,6 +137,16 @@ func NewSQLiteWriter(dbPath string) (*SQLiteWriter, error) { return nil, fmt.Errorf("create schema: %w", err) } + // Backward-compat: an incremental build may open a nodes table written + // before the context column existed. CREATE TABLE IF NOT EXISTS won't + // add it, so ALTER it in (mache-b8fe72). Fresh tables already have it. + if !graph.ColumnExists(db, "nodes", "context") { + if _, err := db.Exec(`ALTER TABLE nodes ADD COLUMN context BLOB`); err != nil { + _ = db.Close() + return nil, fmt.Errorf("add nodes.context column: %w", err) + } + } + w := &SQLiteWriter{ db: db, batchSize: defaultBatchSize, @@ -153,8 +168,8 @@ func (w *SQLiteWriter) beginTx() error { } // Prepare statement for fast inserts w.stmtNode, err = w.tx.Prepare(` - INSERT OR REPLACE INTO nodes (id, parent_id, name, kind, size, mtime, record_id, record, source_file) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT OR REPLACE INTO nodes (id, parent_id, name, kind, size, mtime, record_id, record, source_file, context) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { return err @@ -443,7 +458,8 @@ func (w *SQLiteWriter) AddNode(n *graph.Node) { sourceFile = &sf } - // 6. Insert + // 6. Insert. n.Context (imports/types for the `context` vfile) is stored + // in its own column so it survives to the mount — nil serializes to NULL. _, err := w.stmtNode.Exec( n.ID, parentID, @@ -454,6 +470,7 @@ func (w *SQLiteWriter) AddNode(n *graph.Node) { recordID, record, sourceFile, + n.Context, ) if err != nil { log.Printf("SQLiteWriter: insert failed for %s: %v", n.ID, err) @@ -570,10 +587,11 @@ func (w *SQLiteWriter) GetNode(id string) (*graph.Node, error) { var kind int var mtimeNano int64 var record sql.NullString + var context []byte err := w.tx.QueryRow( - "SELECT kind, mtime, record FROM nodes WHERE id = ?", + "SELECT kind, mtime, record, context FROM nodes WHERE id = ?", id, - ).Scan(&kind, &mtimeNano, &record) + ).Scan(&kind, &mtimeNano, &record, &context) if err == sql.ErrNoRows { return nil, graph.ErrNotFound } @@ -586,10 +604,14 @@ func (w *SQLiteWriter) GetNode(id string) (*graph.Node, error) { mode = os.ModeDir | 0o555 } + // Context must round-trip: the engine re-reads a node here between the + // two write passes; dropping it would let the second-pass INSERT OR + // REPLACE null out node.Context (mache-b8fe72, cf. Properties/mache-d28eb1). n := &graph.Node{ ID: id, Mode: mode, ModTime: time.Unix(0, mtimeNano), + Context: context, } // Round-trip record JSON → Properties. Only meaningful for dir // nodes (kind=1); file nodes (kind=0) carry rendered template diff --git a/internal/ingest/sqlite_writer_test.go b/internal/ingest/sqlite_writer_test.go index 85674644..aa743feb 100644 --- a/internal/ingest/sqlite_writer_test.go +++ b/internal/ingest/sqlite_writer_test.go @@ -58,6 +58,43 @@ func TestSQLiteWriter_GetNode_RoundTripsProperties(t *testing.T) { "pkg Property must survive the round-trip too") } +// TestSQLiteWriter_RoundTripsContext pins bead mache-b8fe72 on the write +// side: node.Context (the imports/types the headline `cat context` vfile +// serves) must (1) persist into the nodes.context column and (2) survive +// SQLiteWriter.GetNode, which the engine's two-pass write re-reads to +// preserve fields — same class as the Properties fix (mache-d28eb1). If +// GetNode drops Context, the second-pass INSERT OR REPLACE nulls it even +// after the column exists. +func TestSQLiteWriter_RoundTripsContext(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "ctx.db") + ctx := []byte("import (\n\t\"fmt\"\n)\n") + + w, err := NewSQLiteWriter(dbPath) + require.NoError(t, err) + + w.AddNode(&graph.Node{ + ID: "pkg/methods/Foo.Bar", + Mode: os.ModeDir | 0o555, + ModTime: time.Unix(1700000000, 0), + Context: ctx, + }) + + got, err := w.GetNode("pkg/methods/Foo.Bar") + require.NoError(t, err) + assert.Equal(t, ctx, got.Context, + "SQLiteWriter.GetNode must round-trip Context (two-pass write protection)") + require.NoError(t, w.Close()) + + // The bytes actually landed in the nodes.context column. + db, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + defer func() { _ = db.Close() }() + var stored []byte + require.NoError(t, db.QueryRow( + `SELECT context FROM nodes WHERE id = ?`, "pkg/methods/Foo.Bar").Scan(&stored)) + assert.Equal(t, ctx, stored, "AddNode must persist node.Context to the context column") +} + // LoadFileIndex is the read-side of incremental re-ingestion: when // `mache build` runs over a tree we've seen before, it loads this // table to skip files whose (path, mod_time, size) tuple is