Artifact 59427fbe737a8274f9415a8df880f8379ef2f0fc0bb152983b943f72dfcdd0f3:
- File
internal/manifest/ticket.go
— part of check-in
[2c2e5e74b6]
at
2026-10-09 15:35:38
on branch trunk
— Store check-in, link and tag rows as fossil does (#258) (#261)
* fix(manifest): store check-in, link and tag rows as fossil does (#258)
A rebuilt or locally written repository differed from fossil's in three derived values: event and plink times in their last digits, event.omtime left NULL, and a valueless tag stored as '' instead of NULL.
Fossil stores a tag time as SQLite's julianday() bound as a double, but writes event, plink, attachment and forumpost times into SQL text with its own printf("%.17g"), which emits 16 digits in long double arithmetic. TimeToJulian is now julianday() exactly (whole milliseconds over 86400000) and TimeToTextJulian ports fossil's printf conversion, so both match fossil bit for bit. The conversion follows builds where long double is a double (macOS, Windows); fossil itself differs across platforms in the last digit.
Check-in events record omtime, tag writes bind NULL for an empty value, the readers of event and plink times under the ncruces driver rebuild the text form, and the CLI's tag listing reads NULL values.
* test(db): sweep both stored julian forms; scan the text form from driver times (#258)
* test(verify): compare text times bit for bit only where fossil computes them in double (#258) (user: [email protected], size: 13253) [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 := ticketHashDigits(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 } // ticketHashDigits returns the two hyperlink hash lengths fossil's %S and %!S // conversions use in a ticket event comment: the human length (the hash-digits // setting, default 10, clamped to 6..40) and the url length (human+6, floored // at 16). Mirrors fossil's hashDigits(). func ticketHashDigits(q db.Querier) (human, url int) { human = 10 var v int if q.QueryRow("SELECT value FROM config WHERE name='hash-digits'").Scan(&v) == nil && v != 0 { human = v } if human < 6 { human = 6 } if human > 40 { human = 40 } url = human + 6 if url < 16 { url = 16 } return human, url } // 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 := ticketHTML.Replace(titleCol.String) status := ticketHTML.Replace(statusCol.String) long, short := ticketShorten(d.K, url), ticketShorten(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 = ticketHTML.Replace(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 } // ticketHTML escapes exactly what fossil's %h conversion escapes, in the same // forms. Go's html.EscapeString agrees on every character except the double // quote, which it writes as " where fossil writes ". var ticketHTML = strings.NewReplacer( "&", "&", "<", "<", ">", ">", `"`, """, "'", "'", ) // ticketShorten truncates a hash for display, matching fossil's %S and %!S // conversions: shorter values pass through unchanged. func ticketShorten(hash string, n int) string { if len(hash) > n { return hash[:n] } return hash } func ticketPlural(n int) string { if n == 1 { return "" } return "s" }