Artifact d96087f10aacef0e0637ea963f97ff0f5c8444a16184db47aa7a88e1d0bf45ba:
- File
internal/manifest/log.go
— part of check-in
[ad09ba3d97]
at
2026-07-19 18:27:06
on branch trunk
— fix: give Timeline its own result type so an Ancestry cursor cannot compile into it (#78)
* fix: give Timeline its own result type so an Ancestry cursor cannot compile into it
LogEntry (Ancestry's result type) carried a Cursor field that was always the zero value on Ancestry entries — structurally identical to a legitimate Timeline first-page call. Feeding it into TimelineOpts.After silently paginated from page one forever with no error; documenting the field as invalid on Ancestry entries did nothing to stop it, because the zero cursor cannot be told apart from a valid first-call state even in principle.
Split the shared type instead of adding a runtime guard: LogEntry loses Cursor entirely and remains Ancestry's result type, while a new TimelineEntry (embeds LogEntry, adds Cursor) becomes Timeline's result type. An Ancestry entry's cursor can no longer be obtained at all, so passing one to Timeline is now a compile error rather than a documented hazard. Applied the same split to the internal manifest.LogEntry / manifest.TimelineEntry pair Timeline and Ancestry are built on.
Breaking change; CHANGELOG documents the migration.
* doc: warn against hand-assembling TimelineEntry from an Ancestry LogEntry
Review found the one remaining escape hatch: TimelineEntry{LogEntry: someAncestryEntry} compiles with a zero-value Cursor, which Timeline reads as "start from newest" rather than an error. No library path produces that value — it requires deliberately embedding an Ancestry LogEntry into a hand-built TimelineEntry — but flag it at the type so whoever does that gets the warning where they're typing. (user: [email protected], size: 4074) [annotate] [blame] [check-ins using]
package manifest import ( "database/sql" "fmt" "time" "github.com/danmestas/libfossil/db" libfossil "github.com/danmestas/libfossil/internal/fsltype" "github.com/danmestas/libfossil/internal/repo" ) type LogOpts struct { Start libfossil.FslID Limit int } // LogEntry represents a single event as returned by Log (the ancestry // walk). It carries no pagination cursor: Log paginates by Start/Limit, // not by cursor, so there is nothing valid to put in one. See // TimelineEntry, Timeline's result type, for the cursor-carrying // counterpart. type LogEntry struct { RID libfossil.FslID UUID string Comment string User string Time time.Time Kind libfossil.EventKind Parents []string } // maxAncestryDepth bounds the first-parent walk in Log so that a cyclic or // otherwise corrupt plink table cannot hang the process (TigerStyle: every // loop needs an explicit, defensible bound). It is a var rather than a // const so tests can shrink it to exercise the bound deterministically // without walking a million rows. No real fossil repository's primary-parent // chain approaches this depth. var maxAncestryDepth = 1_000_000 func Log(r *repo.Repo, opts LogOpts) ([]LogEntry, error) { if r == nil { panic("manifest.Log: r must not be nil") } if opts.Start <= 0 { return nil, fmt.Errorf("manifest.Log: invalid start rid %d", opts.Start) } var entries []LogEntry current := opts.Start for depth := 0; ; depth++ { if depth >= maxAncestryDepth { return nil, fmt.Errorf("manifest.Log: exceeded max ancestry depth %d starting at rid=%d (possible plink cycle)", maxAncestryDepth, opts.Start) } if opts.Limit > 0 && len(entries) >= opts.Limit { break } // event.user and event.comment are nullable in fossil's schema: the // U-card and C-card are optional on a check-in manifest (the only // required cards are "D" and "Z"). A NULL here is valid data, not // malformed input, so scan through sql.NullString and let its zero // value ("") stand in for "no value recorded" — the same substitution // fossil itself makes when reading these columns. var uuid string var user, comment sql.NullString var mtimeScanned any err := r.DB().QueryRow( "SELECT b.uuid, e.user, e.comment, e.mtime FROM blob b JOIN event e ON e.objid=b.rid WHERE b.rid=?", current, ).Scan(&uuid, &user, &comment, &mtimeScanned) if err != nil { return nil, fmt.Errorf("manifest.Log: rid=%d: %w", current, err) } // mtime is a julianday float. modernc returns float64; // ncruces returns time.Time for DATETIME/TIMESTAMP columns. Handle both. mtime, ok := db.ScanJulianDay(mtimeScanned) if !ok { return nil, fmt.Errorf("manifest.Log: rid=%d: unexpected mtime type %T", current, mtimeScanned) } entries = append(entries, LogEntry{ RID: current, UUID: uuid, Comment: comment.String, User: user.String, Time: libfossil.JulianToTime(mtime), Kind: libfossil.EventKindCheckin, Parents: parentUUIDs(r, current), }) var parentRid int64 if err := r.DB().QueryRow( "SELECT pid FROM plink WHERE cid=? AND isprim=1", current, ).Scan(&parentRid); err != nil { break } current = libfossil.FslID(parentRid) } return entries, nil } // parentUUIDs returns the UUIDs of cid's parents (primary parent first), // via the plink table. Best-effort: a query or scan failure yields a nil // (empty) result rather than aborting the caller's walk or enumeration — // matching the original manifest.Log behavior this was extracted from. // Shared by Log (ancestry walk) and Timeline (enumeration) so entry // construction is not duplicated between the two query paths. func parentUUIDs(r *repo.Repo, cid libfossil.FslID) []string { var parents []string rows, err := r.DB().Query( "SELECT b.uuid FROM plink p JOIN blob b ON b.rid=p.pid WHERE p.cid=? ORDER BY p.isprim DESC", cid, ) if err != nil { return parents } defer rows.Close() for rows.Next() { var puuid string if err := rows.Scan(&puuid); err != nil { continue } parents = append(parents, puuid) } return parents }