{"record":{"id":"831a0857149fc471","repo":"gastownhall/beads","slug":"adding-s-content-hash-w","errorCode":null,"errorMessage":"adding %s.content_hash: %w","messagePattern":"adding (.+?)\\.content_hash: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/schema/schema.go","lineNumber":1153,"sourceCode":"// table that predates it (gastownhall/beads#4259 reporter fix No.2: record a\n// per-migration content hash so two clones at the same MAX(version) but with\n// divergent migration content are detectable). Fresh tables already have it via\n// bootstrapSQL; this idempotently upgrades older databases without a numbered\n// migration. Already-applied rows keep a NULL hash — their migration content is\n// not re-read. It reports whether it actually added the column, so MigrateUp can\n// treat that ALTER as committable schema work even when no numbered migration or\n// backfill ran.\nfunc (m migrationSource) ensureContentHashColumn(ctx context.Context, db DBConn) (bool, error) {\n\thas, err := m.hasContentHashColumn(ctx, db)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif has {\n\t\treturn false, nil\n\t}\n\t//nolint:gosec // G201: m.cursorTable is a hardcoded constant.\n\tif _, err := db.ExecContext(ctx, \"ALTER TABLE \"+m.cursorTable+\" ADD COLUMN content_hash CHAR(64)\"); err != nil {\n\t\treturn false, fmt.Errorf(\"adding %s.content_hash: %w\", m.cursorTable, err)\n\t}\n\treturn true, nil\n}\n\nfunc checkNoDuplicateVersions(files []migrationFile) {\n\tseen := make(map[int]string, len(files))\n\tfor _, m := range files {\n\t\tif prior, ok := seen[m.version]; ok {\n\t\t\tpanic(fmt.Sprintf(\n\t\t\t\t\"schema: duplicate migration version %d: %q and %q — renumber one before commit\",\n\t\t\t\tm.version, prior, m.name,\n\t\t\t))\n\t\t}\n\t\tseen[m.version] = m.name\n\t}\n}\n\nfunc (m migrationSource) list() []migrationFile {","sourceCodeStart":1135,"sourceCodeEnd":1171,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/schema/schema.go#L1135-L1171","documentation":"ensureContentHashColumn upgrades an older cursor table by running `ALTER TABLE <cursor> ADD COLUMN content_hash CHAR(64)`. If the ALTER fails, the error is wrapped as `adding <cursor>.content_hash: <cause>`. This is part of an idempotent bootstrap upgrade (beads#4259), so failures here block migration bookkeeping from recording content hashes.","triggerScenarios":"The ALTER runs only when SHOW COLUMNS confirmed the column is absent; it fails on insufficient privileges (ALTER denied), the table being dropped between probe and ALTER, a concurrent writer holding metadata locks, or disk-full/online-DDL limits on the server.","commonSituations":"Restricted DB user without ALTER privilege on an ops-managed database; two processes migrating concurrently racing the ALTER; managed MySQL (non-Dolt) with online DDL restrictions; read-only replica receiving writes.","solutions":["GRANT ALTER on the schema to the application DB user, or run the upgrade once with a privileged account.","Ensure only one migrator runs: acquire an application-level lock (e.g. GET_LOCK or a lock table) around MigrateUp.","Re-run MigrateUp — the flow is idempotent; if the table vanished, bootstrapSQL recreates it including content_hash.","Check server disk space and DDL error details in the wrapped cause; on read-only replicas, point migrations at the primary."],"exampleFix":"// before\nif _, err := db.ExecContext(ctx, \"ALTER TABLE \"+m.cursorTable+\" ADD COLUMN content_hash CHAR(64)\"); err != nil {\n    return false, fmt.Errorf(\"adding %s.content_hash: %w\", m.cursorTable, err)\n}\n// after\nif _, err := db.ExecContext(ctx, \"ALTER TABLE \"+m.cursorTable+\" ADD COLUMN IF NOT EXISTS content_hash CHAR(64)\"); err != nil {\n    if dberrors.IsDuplicateColumn(err) {\n        return false, nil // concurrent migrator already added it\n    }\n    return false, fmt.Errorf(\"adding %s.content_hash: %w\", m.cursorTable, err)\n}","handlingStrategy":"retry","validationCode":"// verify ALTER privilege and that the cursor table exists before bootstrap\nvar priv int\nerr := db.QueryRowContext(ctx, \"SELECT COUNT(*) FROM information_schema.schema_privileges WHERE grantee LIKE CURRENT_USER() AND privilege_type = 'ALTER'\").Scan(&priv)\nif err == nil && priv == 0 {\n    return fmt.Errorf(\"DB user lacks ALTER privilege needed for content_hash upgrade\")\n}","typeGuard":"func isDuplicateColumnErr(err error) bool {\n    var mysqlErr *mysql.MySQLError\n    return errors.As(err, &mysqlErr) && mysqlErr.Number == 1060\n}","tryCatchPattern":"err := MigrateUp(ctx, db)\nif err != nil && strings.Contains(err.Error(), \"adding \") && strings.Contains(err.Error(), \"content_hash\") {\n    if isDuplicateColumnErr(err) || isLockWaitErr(err) {\n        time.Sleep(time.Second)\n        err = MigrateUp(ctx, db) // idempotent: probe re-checks before ALTER\n    }\n}","preventionTips":["Grant the app user ALTER on the schema, or run the one-time upgrade as an admin.","Use a migration lock so only one process upgrades the cursor table.","Re-run MigrateUp after failure — the ensure step is idempotent.","Keep the server's disk from filling; ALTERs fail on full disks."],"tags":["database","migration","alter-table","permissions"],"backgroundTag":"alter-table-add-column-failed","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}