Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions internal/graph/context_roundtrip_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
59 changes: 42 additions & 17 deletions internal/graph/nodes_table_reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"),
}
}

Expand All @@ -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
}
Expand All @@ -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 {
Expand Down
34 changes: 28 additions & 6 deletions internal/ingest/sqlite_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
37 changes: 37 additions & 0 deletions internal/ingest/sqlite_writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading