package memory import ( "context" "database/sql" "fmt" "strings" "time" ) // timeLayout is the fixed-width timestamp layout used for every stored // created_at/updated_at value. Unlike time.RFC3339Nano it never drops trailing // fractional zeros, so every value is exactly storedTimeLen bytes wide. SQLite // compares these TEXT columns as plain strings, and equal width is what makes // that comparison agree with chronological order. UTC values render the zone as // a single "Z". const timeLayout = "2006-01-02T15:04:05.000000000Z07:00" // storedTimeLen is the byte length of a UTC value formatted with timeLayout: // "2006-01-02T15:04:05.000000000Z" — the Z07:00 zone collapses to "Z" in UTC. const storedTimeLen = 30 // memoryItemsTable is the table whose timestamps are normalized when the store // opens. It must match the DDL in store.go. const memoryItemsTable = "memory_items" // formatTime encodes t in UTC with the fixed-width timeLayout. Always writing // UTC keeps ordering and round-trips stable across processes, and the fixed // width is what lets ORDER BY created_at order these TEXT columns by time. func formatTime(t time.Time) string { return t.UTC().Format(timeLayout) } // parseTime decodes a stored timestamp. time.RFC3339Nano accepts both the // fixed-width values this package writes and the legacy variable-width ones, so // the same parser serves reads and the open-time normalization. Blank input // yields a zero time. func parseTime(s string) (time.Time, error) { if strings.TrimSpace(s) == "" { return time.Time{}, nil } t, err := time.Parse(time.RFC3339Nano, s) if err != nil { return time.Time{}, fmt.Errorf("memory: parse time %q: %w", s, err) } return t, nil } // normalizeMemoryTimestamps rewrites every memory_items timestamp that is not // storedTimeLen bytes wide into the fixed-width timeLayout. Rows written before // the layout existed carry variable-width RFC3339Nano text whose string order // is not its time order; this brings them in line. // // It is idempotent: only rows whose column length differs from storedTimeLen are // selected, so running it again on a normalized table is a no-op. A row whose // value does not parse is left exactly as it is — one bad legacy value must not // keep the store from opening. func normalizeMemoryTimestamps(ctx context.Context, db *sql.DB) error { if err := normalizeTimeColumn(ctx, db, memoryItemsTable, "created_at"); err != nil { return err } return normalizeTimeColumn(ctx, db, memoryItemsTable, "updated_at") } // normalizeTimeColumn rewrites every table.col value that is not storedTimeLen // bytes wide. table and col are compile-time constants, never user input, so // splicing them into the statement is safe; the length comparison and every // value written are parameterized. All rewrites are collected before any UPDATE // runs, so no write happens while the SELECT cursor is still open. func normalizeTimeColumn(ctx context.Context, db *sql.DB, table, col string) error { fixes, err := collectLegacyTimestamps(ctx, db, table, col) if err != nil { return err } for _, f := range fixes { if _, err := db.ExecContext(ctx, "UPDATE "+f.table+" SET "+f.col+" = ? WHERE id = ?", f.value, f.id); err != nil { return fmt.Errorf("memory: normalize %s.%s: %w", f.table, f.col, err) } } return nil } // timeFix is one pending timestamp rewrite: which table and column, which row // (by primary key id), and the fixed-width value to write. type timeFix struct { table string col string id string value string } // collectLegacyTimestamps reads the rows of table.col whose value is not // storedTimeLen bytes wide and returns the parseable ones as pending rewrites. // Unparseable values are skipped, so normalization can never fail a row: the // only errors are genuine read failures. func collectLegacyTimestamps(ctx context.Context, db *sql.DB, table, col string) ([]timeFix, error) { rows, err := db.QueryContext(ctx, "SELECT id, "+col+" FROM "+table+" WHERE length("+col+") != ?", storedTimeLen) if err != nil { return nil, fmt.Errorf("memory: query legacy %s.%s: %w", table, col, err) } defer func() { _ = rows.Close() }() fixes := make([]timeFix, 0) for rows.Next() { var id, raw string if err := rows.Scan(&id, &raw); err != nil { return nil, fmt.Errorf("memory: scan legacy %s.%s: %w", table, col, err) } fixed, ok := reformatTime(raw) if !ok { continue } fixes = append(fixes, timeFix{table: table, col: col, id: id, value: fixed}) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("memory: iterate legacy %s.%s: %w", table, col, err) } return fixes, nil } // reformatTime re-encodes raw with timeLayout. The bool is false when raw is // already storedTimeLen wide or when it does not parse, in which case the caller // leaves the stored value untouched. func reformatTime(raw string) (string, bool) { if len(raw) == storedTimeLen { return "", false } t, err := parseTime(raw) if err != nil || t.IsZero() { return "", false } return formatTime(t), true }