{"record":{"id":"dfbf3456b58a6fd5","repo":"juanfont/headscale","slug":"creating-oauth-clients-table-w","errorCode":null,"errorMessage":"creating oauth_clients table: %w","messagePattern":"creating oauth_clients table: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"critical","filePath":"hscontrol/db/db.go","lineNumber":869,"sourceCode":"\n\t\t\t\t\tif tx.Name() != \"sqlite\" {\n\t\t\t\t\t\treturn tx.AutoMigrate(&types.OAuthClient{}, &types.OAuthAccessToken{})\n\t\t\t\t\t}\n\n\t\t\t\t\tif !tx.Migrator().HasTable(&types.OAuthClient{}) {\n\t\t\t\t\t\terr := tx.Exec(`CREATE TABLE oauth_clients(\n  id integer PRIMARY KEY AUTOINCREMENT,\n  client_id text,\n  secret_hash blob,\n  scopes text,\n  tags text,\n  description text,\n  user_id integer,\n  created_at datetime,\n  revoked datetime\n)`).Error\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\treturn fmt.Errorf(\"creating oauth_clients table: %w\", err)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\terr = tx.Exec(`CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id)`).Error\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\treturn fmt.Errorf(\"creating oauth_clients index: %w\", err)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tif !tx.Migrator().HasTable(&types.OAuthAccessToken{}) {\n\t\t\t\t\t\terr := tx.Exec(`CREATE TABLE oauth_access_tokens(\n  id integer PRIMARY KEY AUTOINCREMENT,\n  prefix text,\n  hash blob,\n  client_id text,\n  scopes text,\n  tags text,\n  expiration datetime,\n  created_at datetime","sourceCodeStart":851,"sourceCodeEnd":887,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/db/db.go#L851-L887","documentation":"The migration creating the oauth_clients table (raw CREATE TABLE guarded by HasTable) for the v2 API's OAuth client-credentials flow fails. Because it is guarded by HasTable, an existing table is skipped, so failure means genuine DDL rejection: missing CREATE privilege, read-only or full database, dialect problems with the raw SQL, or a leftover catalog object with the same name but a different kind (sequence, view).","triggerScenarios":"Creating oauth_clients when the DB user cannot CREATE tables, the database is read-only or out of space, or a leftover object named oauth_clients (from aborted experiments with the v2 API tables) confuses the catalog.","commonSituations":"Upgrading on locked SQLite databases; Postgres schemas where CREATE was revoked; leftover objects from earlier failed attempts at the v2 API tables.","solutions":["Read the wrapped error: 'permission denied for schema' means GRANT CREATE ON SCHEMA; 'attempt to write a readonly database' means fix the mount; 'database is locked' means serialize instances","If a non-table object named oauth_clients exists, drop it after review, then restart - HasTable will then attempt creation again","Free disk space; large SQLite files may need VACUUM after failed DDL attempts","Verify post-startup: SELECT * FROM oauth_clients LIMIT 1 should succeed (empty result)"],"exampleFix":null,"handlingStrategy":"validation","validationCode":"// Pre-flight: no object name collisions, DDL permitted (SQLite)\nvar kind string\nerr := db.QueryRow(`SELECT type FROM sqlite_master WHERE name = 'oauth_clients'`).Scan(&kind)\nif err == nil && kind != \"table\" {\n\tlog.Fatal(\"non-table object named oauth_clients exists; remove it\")\n}\nif _, err := db.Exec(\"CREATE TABLE IF NOT EXISTS _probe(id integer)\"); err != nil {\n\tlog.Fatalf(\"DDL blocked: %v\", err)\n}\ndb.Exec(\"DROP TABLE IF EXISTS _probe\")","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Don't hand-create experimental tables with the same names as upcoming migrations","Keep disk headroom (DDL + WAL growth) before upgrades","Grant CREATE on schema to the headscale role"],"tags":["database","migration","oauth","ddl","create-table"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}