Artifact ac7fd577dad86aaf18468711677df004f48ec972a94e3ddba37f2a5f9e5f8e53:
- File
internal/manifest/ticket.go
— part of check-in
[087e1c7ce3]
at
2026-10-18 21:49:37
on branch feat/submanifest-reader
— fix(manifest): derive the event rows fossil derives for tags and attachments
A control artifact's 'g' event comment now matches fossil byte for byte: each target introduced by an Edit link, changes sorted by target hash and full tag name, sym- tags named as tags, a branch move linked to its timeline with its own sym- changes left out, values HTML-escaped, no leading space. tag.InsertControlEvent owns the row, and AddTag writes it, as fossil's tag add does; it was missing until a rebuild or clone.
Attachment comments link the attached artifact and the ticket or tech note as fossil 2.28 does, and a deleted attachment's src is NULL. A check-in without a C card gets a NULL event comment. Hash prefixes follow hash-digits up to 64 digits (was capped at 40); the printf helpers move to fsltype so tag, ticket and attachment comments share them.
Fossil-binary oracles: a fossil-made history with tags, branch moves, wiki, tickets, tech notes, forum posts and attachments rebuilt by both, and a go-written history rebuilt by fossil, must give identical event and attachment tables. (user: [email protected], size: 12097) [annotate] [blame] [check-ins using]
package manifest import ( "database/sql" "fmt" "strings" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/content" "github.com/danmestas/go-libfossil/internal/deck" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" ) // ticketRebuildEntry re-derives every row one ticket owns -- its ticket row, // its ticketchng rows and one event row per change artifact -- by replaying // every artifact carrying the tkt-<uuid> tag in mtime order. It is fossil's // ticket_rebuild_entry (src/ticket.c), which fossil defers to the end of a // crosslink run through a pending_tkt table. // // We run it inline instead, inside the caller's transaction, because the // deferral is what issue #184 turned into silent data loss: crosslinkTicket // wrote the tkt- tag immediately and left the event row to a later pass, and // collectCrosslinkCandidates excludes any blob that already has a tagxref row // with its own rid as srcid. A ticket artifact whose deferred half never ran // was therefore both eventless and permanently unselectable, with nothing // logged. Running the rebuild here makes the tag and the event row one unit of // work: they commit together or not at all, so no partial state can exclude // the artifact from a later sweep. // // Replaying the whole tag set per artifact rather than once per run is what // buys that atomicity, and it is also what makes the result independent of // arrival order -- a change artifact that arrives before the create artifact // it amends produces the same final rows either way, because the last replay // to run rewrites all of them. // // Cost, measured, so that nobody has to rediscover it from a profile: this is // quadratic in one ticket's artifact count -- 50 artifacts 47ms, 200 431ms, // 400 1.5s -- and flat in everything else, because a ticket's replay set is // only its own artifacts. Fossil's own repository averages about two artifacts // per ticket, so the real figure there is noise. // // The cheaper shape is to collect ticket uuids during a batch and rebuild each // one once before the transaction commits; that stays atomic and would collapse // the quadratic term. It is deliberately not what this does. It requires both // linkBatch and cascadeLinker.flush to remember to make that call, and a // forgotten call reintroduces issue #184 exactly: rows missing, nothing logged, // test-integrity clean, and the tag already written to keep the artifact hidden // from every later sweep. That is the second bug of the shape this package has // shipped (see also #180). If you are here to optimize a repository with one // hot ticket, move the call, do not drop it -- and prove it with // TestCrosslink_TicketArtifactsProduceEventRows and TestFossilBinaryTicketParity. func ticketRebuildEntry(tx *db.Tx, cache *content.Cache, ticketUUID string) error { if tx == nil { panic("ticketRebuildEntry: tx must not be nil") } if ticketUUID == "" { panic("ticketRebuildEntry: ticketUUID must not be empty") } var tagID int64 if err := tx.QueryRow("SELECT tagid FROM tag WHERE tagname=?", "tkt-"+ticketUUID).Scan(&tagID); err != nil { return fmt.Errorf("ticket tagid: %w", err) } tktCols, err := ticketColumns(tx, "ticket") if err != nil { return err } chngCols, err := ticketColumns(tx, "ticketchng") if err != nil { return err } human, url := libfossil.HashDigits(tx) // The ticket row is rebuilt from scratch so a replay reflects only the // artifacts that exist now; its ticketchng rows go with it, since they are // keyed by the tkt_id this DELETE drops. The event rows need no delete: // event.objid is the primary key, so each replay REPLACEs its own row. if _, err := tx.Exec("DELETE FROM ticketchng WHERE tkt_id IN (SELECT tkt_id FROM ticket WHERE tkt_uuid=?)", ticketUUID); err != nil { return fmt.Errorf("ticket clear ticketchng: %w", err) } if _, err := tx.Exec("DELETE FROM ticket WHERE tkt_uuid=?", ticketUUID); err != nil { return fmt.Errorf("ticket clear ticket: %w", err) } // mtime is fossil's replay order; rid breaks exact ties, which fossil // leaves to whatever order SQLite happens to scan in. Two syncs delivering // the same artifacts in different orders must produce the same rows. rows, err := tx.Query("SELECT rid FROM tagxref WHERE tagid=? ORDER BY mtime, rid", tagID) if err != nil { return fmt.Errorf("ticket replay query: %w", err) } var rids []libfossil.FslID for rows.Next() { var rid int64 if err := rows.Scan(&rid); err != nil { rows.Close() return fmt.Errorf("ticket replay scan: %w", err) } rids = append(rids, libfossil.FslID(rid)) } if err := rows.Err(); err != nil { rows.Close() return fmt.Errorf("ticket replay rows: %w", err) } rows.Close() var tktID int64 // 0 until the first replayable artifact creates the row. for _, rid := range rids { data, err := expandManifestBytes(tx, cache, rid) if err != nil { continue // blob not expandable yet; a later sweep replays it } d, err := deck.Parse(data) if err != nil || d.Type != deck.Ticket || d.K != ticketUUID { continue } isNew := tktID == 0 tktID, err = ticketInsert(tx, d, rid, tktID, tktCols, chngCols) if err != nil { return err } if err := ticketEvent(tx, rid, d, isNew, tagID, human, url); err != nil { return err } } return nil } // ticketColumns returns the columns of one ticket table a J-card may write. // Fossil's getAllTicketFields reads them from the live schema and skips the // tkt_-prefixed bookkeeping columns it manages itself, because a repository // may configure its own ticket fields; we do the same rather than assume the // default schema. table must be a literal we control -- PRAGMA takes no // parameters. func ticketColumns(q db.Querier, table string) (map[string]bool, error) { if table != "ticket" && table != "ticketchng" { panic("ticketColumns: unknown table " + table) } rows, err := q.Query("PRAGMA table_info(" + table + ")") if err != nil { return nil, fmt.Errorf("ticket columns %s: %w", table, err) } defer rows.Close() cols := make(map[string]bool) for rows.Next() { var cid int var name string var typ, dfltValue any var notNull, pk int if err := rows.Scan(&cid, &name, &typ, ¬Null, &dfltValue, &pk); err != nil { return nil, fmt.Errorf("ticket columns %s scan: %w", table, err) } if strings.HasPrefix(name, "tkt_") { continue } cols[name] = true } if err := rows.Err(); err != nil { return nil, fmt.Errorf("ticket columns %s rows: %w", table, err) } return cols, nil } // ticketAssignment is one J-card resolved against a ticket table's columns. type ticketAssignment struct { column string value string append bool // the card was +name: concatenate onto the stored value } // ticketAssignments resolves a ticket artifact's J-cards against cols, keeping // wire order and dropping cards that name no column. A leading '+' selects // append semantics and is not part of the column name. A column named twice -- // which the ascending-J-run rule permits only as `name` plus `+name` -- keeps // the first card, so the statement built from this never assigns one column // twice. func ticketAssignments(d *deck.Deck, cols map[string]bool) []ticketAssignment { var out []ticketAssignment seen := make(map[string]bool, len(d.J)) for _, f := range d.J { name, isAppend := strings.CutPrefix(f.Name, "+") if !cols[name] || seen[name] { continue } seen[name] = true out = append(out, ticketAssignment{column: name, value: f.Value, append: isAppend}) } return out } // ticketInsert applies one ticket artifact to the ticket and ticketchng // tables, mirroring fossil's ticket_insert (src/ticket.c). tktID is 0 when // this is the first artifact of its ticket being replayed, in which case the // ticket row is created here; the id in force afterwards is returned. func ticketInsert( tx *db.Tx, d *deck.Deck, rid libfossil.FslID, tktID int64, tktCols, chngCols map[string]bool, ) (int64, error) { mtime := libfossil.TimeToJulian(d.D) if tktID == 0 { // tkt_ctime is the creating artifact's date and never moves again; // tkt_mtime is set by the UPDATE below, so every replay advances it to // the latest artifact applied. res, err := tx.Exec( "INSERT INTO ticket(tkt_uuid, tkt_mtime, tkt_ctime) VALUES(?, 0, ?)", d.K, mtime) if err != nil { return 0, fmt.Errorf("ticket insert: %w", err) } tktID, err = res.LastInsertId() if err != nil { return 0, fmt.Errorf("ticket insert id: %w", err) } } set := []string{"tkt_mtime=?"} args := []any{mtime} for _, a := range ticketAssignments(d, tktCols) { if a.append { set = append(set, fmt.Sprintf("%s=coalesce(%s,'') || ?", a.column, a.column)) } else { set = append(set, a.column+"=?") } args = append(args, a.value) } args = append(args, tktID) if _, err := tx.Exec("UPDATE ticket SET "+strings.Join(set, ", ")+" WHERE tkt_id=?", args...); err != nil { return 0, fmt.Errorf("ticket update: %w", err) } cols := []string{"tkt_id", "tkt_rid", "tkt_mtime", "tkt_user"} vals := []any{tktID, rid, mtime, d.U} for _, a := range ticketAssignments(d, chngCols) { cols = append(cols, a.column) vals = append(vals, a.value) } placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(cols)), ", ") if _, err := tx.Exec( "INSERT INTO ticketchng("+strings.Join(cols, ", ")+") VALUES("+placeholders+")", vals..., ); err != nil { return 0, fmt.Errorf("ticketchng insert: %w", err) } return tktID, nil } // ticketEvent writes the timeline row for one ticket artifact, mirroring // fossil's manifest_ticket_event (src/manifest.c). The wording is fossil's, // verified against `fossil rebuild` output for each of its three branches: the // creating artifact, a later artifact that sets a status, and a later artifact // that does not. // // The title and status it renders come from the ticket table as it stands // after this artifact was applied, not from the artifact -- a change artifact // carries only the fields it changes, so its own cards rarely include either. func ticketEvent( tx *db.Tx, rid libfossil.FslID, d *deck.Deck, isNew bool, tagID int64, human, url int, ) error { var titleCol, statusCol sql.NullString if err := tx.QueryRow( "SELECT title, status FROM ticket WHERE tkt_uuid=?", d.K, ).Scan(&titleCol, &statusCol); err != nil && err != sql.ErrNoRows { return fmt.Errorf("ticket event state: %w", err) } title := libfossil.HTMLize(titleCol.String) status := libfossil.HTMLize(statusCol.String) long, short := libfossil.HashPrefix(d.K, url), libfossil.HashPrefix(d.K, human) // A ticket artifact's status card names the new status; every other card // is counted but not named. The name is compared verbatim, as fossil's is: // a '+status' card appends rather than sets, and counts as a change. newStatus := "" changes := 0 for _, f := range d.J { if f.Name == "status" { newStatus = libfossil.HTMLize(f.Value) continue } changes++ } var comment, brief string switch { case isNew: comment = fmt.Sprintf("New ticket [%s|%s] <i>%s</i>.", long, short, title) brief = fmt.Sprintf("New ticket [%s|%s].", long, short) case newStatus != "": comment = fmt.Sprintf("%s ticket [%s|%s]: <i>%s</i>", newStatus, long, short, title) if changes > 0 { comment += fmt.Sprintf(" plus %d other change%s", changes, ticketPlural(changes)) } brief = fmt.Sprintf("%s ticket [%s|%s].", newStatus, long, short) default: comment = fmt.Sprintf("Ticket [%s|%s] <i>%s</i> status still %s with %d other change%s", long, short, title, status, changes, ticketPlural(changes)) brief = fmt.Sprintf("Ticket [%s|%s]: %d change%s", long, short, changes, ticketPlural(changes)) } if _, err := tx.Exec( "REPLACE INTO event(type, tagid, mtime, objid, user, comment, brief) VALUES('t', ?, ?, ?, ?, ?, ?)", tagID, libfossil.TimeToTextJulian(d.D), rid, d.U, comment, brief, ); err != nil { return fmt.Errorf("ticket event: %w", err) } return nil } func ticketPlural(n int) string { if n == 1 { return "" } return "s" }