Artifact 6dd741dd4d81106042e6f8055992a262c374c8b369374c69c4b9b01424380057:
- File
internal/fsltype/fsltype.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: 4410) [annotate] [blame] [check-ins using]
// Package fsltype defines shared types used across internal packages. // These are re-exported by the root libfossil package. package fsltype import ( "math" "time" ) // FslID is a row-id in the blob table (content-addressed artifacts). type FslID int64 // FslSize represents a blob size; negative values indicate phantom blobs. type FslSize int64 const ( // PhantomSize is the sentinel size for phantom (not-yet-received) blobs. PhantomSize FslSize = -1 // FossilApplicationID is the SQLite application_id for Fossil repositories. FossilApplicationID int32 = 252006673 ) // EventKind is the type discriminator on the event table's newest-first // timeline: one of the single-letter codes fossil assigns per row (see // src/schema.c:303-312). The zero value ("") means "all kinds" — the // canonical default a bare `fossil timeline` uses when no -t/--type is // given (src/timeline.c:timeline_cmd(), guarded by `zType && zType[0]!='a'`). type EventKind string const ( // EventKindCheckin is a check-in ('ci'). EventKindCheckin EventKind = "ci" // EventKindTechnote is a technote / event artifact ('e'). EventKindTechnote EventKind = "e" // EventKindForum is a forum post ('f'). EventKindForum EventKind = "f" // EventKindTag is a tag/control artifact ('g'). EventKindTag EventKind = "g" // EventKindTicket is a ticket change ('t'). EventKindTicket EventKind = "t" // EventKindWiki is a wiki page edit ('w'). EventKindWiki EventKind = "w" ) // Valid reports whether k is the zero value ("all kinds") or one of the // six recognized event.type codes. func (k EventKind) Valid() bool { switch k { case "", EventKindCheckin, EventKindTechnote, EventKindForum, EventKindTag, EventKindTicket, EventKindWiki: return true default: return false } } const julianEpoch = 2440587.5 // TimeToJulian converts a time.Time to a Fossil Julian day number. func TimeToJulian(t time.Time) float64 { return julianEpoch + float64(t.UTC().UnixMilli())/(86400.0*1000.0) } // JulianToTime converts a Fossil Julian day number to time.Time. // // Rounds to the nearest millisecond rather than truncating: a julian value // produced by TimeToJulian for an exact millisecond carries float64 error // on the order of 1e-10 days, which a plain int64() truncation can turn // into a full millisecond of drift when that error lands the value just // below the true integer. Rounding makes the pair a true round trip // (TimeToJulian(JulianToTime(m)) == m) for any m that itself came from // TimeToJulian — the property the Timeline enumeration's composite // (mtime, rid) pagination cursor depends on. func JulianToTime(j float64) time.Time { millis := int64(math.Round((j - julianEpoch) * 86400.0 * 1000.0)) return time.UnixMilli(millis).UTC() } // Cursor is an opaque pagination token for a Timeline enumeration. Obtain // one from a returned TimelineEntry's Cursor field and hand it back as the // next page's cursor to resume immediately after that entry, in the same // (mtime DESC, rid DESC) order Timeline itself produces. The zero Cursor // (Valid() == false) means "start from the newest event". // // The julian/rid pair is deliberately unexported. A cursor built from // parts — e.g. re-deriving mtime by round-tripping a TimelineEntry.Time // back through TimeToJulian — is not guaranteed to equal the row's actual // stored mtime bit-for-bit, which silently reintroduces skipped or // duplicated rows at a page boundary (the exact defect this type exists // to make impossible to construct). Only NewCursor, called by Timeline's // own row-scanning code with the float64 read directly off the row, may // produce a valid one. type Cursor struct { julian float64 rid FslID valid bool } // NewCursor builds a Cursor from a row's exact scanned mtime and rid. // Callers outside this package's Timeline implementation should not need // this — obtain a Cursor from a TimelineEntry instead. func NewCursor(julian float64, rid FslID) Cursor { return Cursor{julian: julian, rid: rid, valid: true} } // Julian returns the cursor's julian-day mtime component. func (c Cursor) Julian() float64 { return c.julian } // RID returns the cursor's rid component. func (c Cursor) RID() FslID { return c.rid } // Valid reports whether c was produced by NewCursor (as opposed to being // a zero-value Cursor{}, which means "no cursor"). func (c Cursor) Valid() bool { return c.valid }